Skip to content

Security

Every endpoint declares who may call it, each workspace only reaches its own data, and tokens and webhooks are verified before anything else. Rate limits, HTTPS, nightly backups and alerts come ready for production.

In short

  • An endpoint without its guards fails the build.
  • One customer never sees another's data, and the tests prove it.
  • Production-ready: one checklist takes you live.

Every endpoint says who may call it

Each query, mutation and route declares an authentication guard and a permission guard. "Public" and "no permission needed" are written out too, so they stand out in review. A missing guard is a lint error.

  • Sign-in is checked once, in a middleware; resolvers never read tokens
  • Signed out gets UNAUTHENTICATED, a missing permission gets FORBIDDEN
packages/app-server/src/modules/project/project.resolver.ts
@Resolver(() => ProjectDto)@UseGuards(WorkspaceAuthGuard)@UseFilters(ProjectGraphqlApiExceptionFilter)export class ProjectResolver {  constructor(private readonly projectService: ProjectService) {}   @Query(() => [ProjectDto])  @UseGuards(NoPermissionGuard)  async projects(   @Mutation(() => ProjectDto)  @UseGuards(WorkspacePermissionGuard('org:projects:create'))  async createProject(

Workspaces can't reach each other

Workspace data is read and written only through a repository that adds the workspace to every query. It refuses a workspace written inside the query, a row moved to another workspace, and an upsert that could land on another workspace's row.

  • The workspace always comes from the signed-in member, never from the request
  • A lint rule refuses any other way to reach workspace tables
  • The API tests check that another workspace gets "not found" for every action
packages/app-server/src/engine/workspace-scoped-repository/workspace-scoped-repository.ts
// The only way services read or write tenant-owned data. Every method takes// the workspaceId first and adds it to the query itself, so forgetting// `where: { workspaceId }` is impossible and a caller can't override it.export class WorkspaceScopedRepository<TEntity extends WorkspaceRelatedEntity> {  constructor(private readonly repository: Repository<TEntity>) {}   async find(    workspaceId: string,    options: FindManyOptions<TEntity> = {},  ): Promise<TEntity[]> {    return this.repository.find({      ...options,      where: this.mergeWorkspaceIdIntoWhere(workspaceId, options.where),    });  }

Tokens are verified, not trusted

Clerk owns passwords, sessions and two-step verification, so your database never stores a credential. The server verifies each session token with Clerk's official helper and your instance's public key, without a network call.

  • Names and emails are read from Clerk by the server, never taken from the browser
  • Permissions come from the member's role, in your code
A request to the API
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…

What the server checks before anything else

  1. Signed with your Clerk instance's key, checked without a network call
  2. Issued for your web app's address
  3. Still valid: tokens live 60 seconds
  4. Clerk's ids swapped for your own
Signed in as Ada Lovelace, admin of Acme team

Webhooks are checked before anything else

Stripe and Clerk sign their webhooks. The server checks the signature on the exact bytes it received, then stores the event once in an inbox, so a replayed event is only handled once.

  • A plan changes only when Stripe's signed webhook says so
  • Your database stores no card, address, tax id or invoice
  • A cron picks up any event left behind, and alerts you if one keeps failing
What the webhook endpoints answer
RequestAnswerWhat happens
Valid signature200Stored once, handled by the worker
Same event again200Not handled twice
Missing or wrong signature400Nothing stored
Provider switched off404Nothing stored

Abuse is capped before it costs you

Sensitive actions are rate-limited, with the counts kept in Redis, so a script or a stuck button can't flood Stripe, Clerk or your database. Oversized GraphQL queries are refused before anything runs, and in production the API doesn't describe its schema to anyone.

  • Beyond a limit, people read "Too many attempts", in their language; webhook senders get a 429 and come back later
  • Clerk's bot protection checks every sign-up
The rate limits of the starter
ActionCounted perAllowed
Opening Stripe Checkout or the billing portalWorkspace10 per minute each
Renaming the workspaceWorkspace10 per minute
Creating a projectPerson30 per minute
Stripe and Clerk webhooksIP address600 per minute each

Production, hardened and watched

The whole product runs on one server with Docker. Only the proxy is reachable: it gets the HTTPS certificates by itself and sends strict security headers. Migrations run on every deploy, the database is saved every night, and you are alerted when something breaks.

  • The app's containers run without root, on a read-only file system
  • JSON logs with a request id; email alerts through Sentry, payment disputes included
  • Secrets your features store are encrypted with AES-256-GCM

Hardened installs, secrets kept out of git

Yarn skips the install scripts of downloaded packages, the most common way npm malware spreads, and refuses versions younger than 3 days. The server validates its configuration when it starts, and secrets live only in git-ignored files.

  • In production, the server refuses to start with the stand-in sign-in, with rate limits off or without its encryption key
  • Unexpected errors reach the browser as "Internal Server Error", without details
  • CI installs from a lockfile that can't change during the run
.yarnrc.yml
enableConstraintsChecks: true enableHardenedMode: true enableScripts: false nodeLinker: node-modules npmMinimalAgeGate: 3d

Questions

Is it ready for production?

Yes. Rate limits, query limits, security headers, automatic HTTPS, nightly backups, logs and alerts are built in. Follow the deploy guide, then the checklist that ends the security guide: production keys, domains, Clerk's production instance and its bot protection, Stripe in live mode, backups copied off the server.

Where are payment details stored?

At Stripe. Your database keeps the subscription's state, never cards, addresses, tax ids or invoices.

Can an AI assistant change production?

The rules say no: assistants get read-only database access, and production only changes through reviewed code that the normal deploy ships.

What if I add file uploads or emails?

They are not included: add them when your product needs them. The security rules already say how to protect them (safe files, cleaned email HTML, checked addresses), and your AI assistant follows those rules when it builds the feature.

Start your next app from a clean base

Pay once and get the full source code, with every future update.