Skip to content
Security

Security

What Neatship protects by default (guarded endpoints, workspace isolation, token and webhook checks, rate limits, GraphQL query limits, bot protection, encrypted secrets, alerts, logs, backups), what is not included, and the complete checklist before your first real customer.

This guide lists what the starter protects out of the box, what it leaves out on purpose, and ends with the complete checklist to follow before real customers use your app. Read it before your first deploy.

What is protected by default

Every endpoint says who may call it

  • Every GraphQL query and mutation, and every REST route, must declare two guards: an authentication guard (who is calling) and a permission guard (what they may do). Two custom lint rules, app/graphql-resolvers-should-be-guarded and app/rest-api-methods-should-be-guarded, fail the build when one is missing. An endpoint can't ship open by accident.
  • "Public" and "no permission needed" must be written explicitly, with PublicEndpointGuard and NoPermissionGuard, so they are visible in review. Today two endpoints are public: the health check (GET /healthz) and the appInfo GraphQL query, which returns the server's environment name (development, production...).
  • Sign-in is checked once per request, in a middleware, before any guard runs. Resolvers never read tokens.
  • A request without a valid session gets UNAUTHENTICATED (the web app then sends the person to sign in); a signed-in member without the permission gets FORBIDDEN.

One customer never sees another's data

Each customer organization is a workspace. All workspaces share the same tables, and every workspace-owned row has a workspaceId column. Several layers keep them apart:

  • Workspace-owned tables are read and written only through the workspace-scoped repository. Each of its methods takes the workspaceId first and adds it to the query itself. It refuses a workspaceId written inside the query (which could override it), refuses to move a row to another workspace, and refuses an upsert whose conflict key doesn't include the workspace.
  • The workspaceId always comes from the signed-in member, never from what the browser sends. Inputs don't even have a workspaceId field.
  • A lint rule (app/prefer-workspace-scoped-repository) refuses a raw repository for workspace-owned tables. Its two exceptions are listed, each with its reason, in packages/app-server/.oxlintrc.json.
  • Tests prove it. The API tests check that a person from another workspace gets NOT_FOUND for every action on a project, as if it didn't exist.

Sign-in tokens are verified, not trusted

  • Clerk owns passwords, sessions, two-step verification, social sign-in and invitations. The app never stores a credential.
  • The server verifies each Clerk session token with Clerk's official helper and the instance's public key (CLERK_JWT_KEY), without a network call. It only accepts tokens issued for your web app's address (FRONTEND_URL).
  • Tokens live 60 seconds. Names, emails and workspace names are read from Clerk's API by the server, never taken from the browser.
  • Permissions come from the member's role, in your code (see Authentication).

Payments come only from Stripe

  • A plan changes only when Stripe's signed webhook says so. The checkout success page grants nothing: anyone can open its address.
  • The app stores no card, address, tax id or invoice: Stripe keeps them.
  • The Stripe API version is pinned. The catalog command refuses a live key unless you add --live.

Webhooks are verified before anything else

  • POST /webhooks/stripe and POST /webhooks/clerk check the provider's signature on the exact bytes received. A missing or wrong signature gets a 400 answer and nothing is stored. A provider that is switched off answers 404.
  • Events are stored in an inbox with a unique key, so a replayed event is processed once. Event types the app doesn't handle are not stored. See Background jobs and webhooks.

Sensitive actions are rate-limited

A rate limit caps how often someone can repeat an action, so a script or a stuck button can't flood Stripe, Clerk or your database. The counts live in Redis, so several API servers share them.

ActionCounted perAllowed
Opening Stripe Checkout, opening the billing portalworkspace10 per minute each
Renaming the workspaceworkspace10 per minute
Creating a projectperson30 per minute
Stripe and Clerk webhookscaller's IP address600 per minute each

Each limit allows a burst: 30 projects can be created at once, then about one every 2 seconds. Beyond the limit, the web app shows "Too many attempts. Wait a moment, then try again." (in French too). Webhook senders get the HTTP answer 429 with a Retry-After header, and Stripe and Clerk send the event again later.

  • To limit another endpoint, add @RateLimit({ scope: 'workspace', maxRequests: 10, windowInMilliseconds: 60_000 }) above its @UseGuards(). The scope is workspace, user or ip. Details: docs/conventions/09-security.md §5.
  • If Redis can't be reached, actions go through and the fault is reported, rather than stopping the product.
  • RATE_LIMITS_ENABLED=false turns them off. Only the automated tests use it; the server refuses it in production.
  • Sign-in, sign-up and password resets happen on Clerk's screens: Clerk limits those itself.

Oversized GraphQL queries are refused

GraphQL lets a caller write one request that asks for a huge amount of data, for example the same relation nested 50 levels deep. The server refuses such requests before running anything. The limits, all changeable in packages/app-server/.env:

SettingDefaultWhat it limits
GRAPHQL_MAX_DEPTH10how deeply fields are nested
GRAPHQL_MAX_ALIASES15aliases, which ask for the same field many times
GRAPHQL_MAX_DIRECTIVES50directives such as @include
GRAPHQL_MAX_TOKENS1000the length of the request (words and symbols), checked while reading it
GRAPHQL_MAX_COST5000an estimate of the work: each field costs 1 or 2, more when nested

The web app's own requests stay far below these limits. A refused request gets the error code BAD_USER_INPUT, a subCode naming the limit (for example GRAPHQL_QUERY_TOO_DEEP) and a translated message.

Inputs and errors

  • One global validation pipe checks every API input against its declared rules and refuses unknown fields. It can't be forgotten on a new endpoint.
  • Malformed ids are refused before they reach the database.
  • An unexpected error (a crash, a database failure) reaches the browser as a generic "Internal Server Error". Its details are added only outside production.
  • The browser-based GraphQL explorer (GraphiQL) is only served when NODE_ENV=development.
  • In production, the API doesn't describe its schema to callers (GraphQL "introspection" is off, for everyone): a request for it gets GRAPHQL_INTROSPECTION_DISABLED. The web app doesn't need it: its types come from the schema file in the repository.
  • The server only accepts browser requests from FRONTEND_URL (CORS: the browser rule that decides which websites may call your API).

Configuration is checked at start-up

  • Every environment variable is declared in one class, packages/app-server/src/engine/core-modules/app-config/config-variables.ts, and validated when the server starts. A missing or malformed value stops the server with a message naming the variable.
  • The server refuses to start in production (NODE_ENV=production) with the mock sign-in (AUTH_PROVIDER=mock, which signs everyone in without a password), with jobs run inside requests (MESSAGE_QUEUE_DRIVER=sync), with rate limits off (RATE_LIMITS_ENABLED=false) or without a valid ENCRYPTION_KEY.
  • Code reads configuration through a typed service, never from process.env directly.

Secrets stay out of git

  • .env files are ignored by git. The committed .env.example files hold empty values or placeholders.
  • packages/app-server/.env.test, used by the tests and committed, holds placeholders only (sk_test_placeholder), never real keys.

Installs and CI are hardened

  • Yarn doesn't run the install scripts of downloaded packages (the most common way npm malware spreads), refuses package versions younger than 3 days, and checks the lockfile against the registry (.yarnrc.yml).
  • CI installs with yarn --immutable --check-cache: the lockfile can't change during a CI run.
  • Every third-party GitHub Action is pinned to an exact commit, and the workflow only gets read access to the repository.
  • Dependabot opens no routine upgrade pull requests (.github/dependabot.yml); upgrades are done on purpose, one at a time. Keep GitHub's security alerts on, so vulnerable versions are still reported.

Security headers in production

The production stack sends security headers from its proxies (packages/app-docker/): HTTPS only (Strict-Transport-Security), a Content Security Policy for the web app that allows scripts only from itself, Clerk and Clerk's bot check, and requests only to your API, Clerk and Sentry (when on), no framing, and no content-type guessing. Details: docs/conventions/09-security.md §10. On your machine (yarn start) these headers are not sent.

Sign-up is protected against bots

Sign-up happens on Clerk's screen, and Clerk's bot sign-up protection checks each sign-up with Cloudflare Turnstile: real people usually see nothing, suspicious visitors must tick a box first. It is a Clerk setting, checked once per Clerk instance (Protect → Rules). The steps: Authentication, "Protect sign-up against bots". The web app's security policy already allows it.

Webhook events are never lost

Each Stripe or Clerk event is stored before it is processed. A cron queues again any event still unprocessed after 15 minutes, sets aside an event that failed for about 5 hours and alerts you about it, and deletes processed events after 30 days (WEBHOOK_EVENT_RETENTION_DAYS). See Background jobs and webhooks.

Disputes, refunds and fraud warnings alert you

When a customer disputes a payment, a bank reports a payment as probable fraud, or a payment is refunded, the app records it with the workspace concerned and alerts you, with a link to the Stripe Dashboard and what to do (respond before the deadline, refund to avoid a dispute...). It never cuts access by itself. See Billing, "Disputes, refunds and fraud warnings".

Logs carry no secrets

In production, the API and the worker write one JSON line per event, with the time, the level, where it was written, the message and the request id (also returned to the browser in the x-request-id header), or the background job being run. Only what the code writes on purpose is logged: never passwords, tokens, request bodies or webhook contents. Error reports sent to Sentry follow the same rule. See Deploy, "Logs".

Nightly backups

The production stack backs up the database every night and keeps the last 7 backups on the server, readable by the deploying user only. Copying them elsewhere is your job: see Deploy, "Backups and restore".

Encrypt the secrets you store

The starter stores no third-party secret in its database: the Clerk and Stripe keys live in .env.production. The day a feature stores one (an access token of a service your customers connect, an API key they give you), encrypt it with SecretEncryptionService, so a stolen database or backup doesn't hand those secrets over.

  • How it protects: AES-256-GCM encryption with ENCRYPTION_KEY. A value changed in the database fails to decrypt instead of returning a wrong secret.
  • Two types the compiler keeps apart: a secret in clear is a PlaintextString, an encrypted one an EncryptedString. Storing plaintext where an encrypted value is expected doesn't compile.
  • The key: required in production (the server refuses to start without it), generated once with openssl rand -base64 32. Locally, bash packages/app-utils/setup-dev-env.sh writes one into a new packages/app-server/.env.

In your feature module, import SecretEncryptionModule, then:

ts
// Saving a token received from another service
const accessToken = this.secretEncryptionService.encrypt(
  PLAINTEXT_STRING_SCHEMA.parse(tokenFromProvider),
);
await this.integrationRepository.update(workspaceId, { id }, { accessToken });

// Using it
const token = this.secretEncryptionService.decrypt(
  ENCRYPTED_STRING_SCHEMA.parse(integration.accessToken),
);

Type the column EncryptedString and add a database check that refuses plaintext, on the entity: @Check('CHK_INTEGRATION_ACCESS_TOKEN_ENCRYPTED', '"accessToken" LIKE \'enc:v1:%\''). Rules and details: docs/conventions/09-security.md §4.

Changing the key (if it leaked, for example): never just replace it, or nothing it encrypted can be read again.

  1. In .env.production, set FALLBACK_ENCRYPTION_KEY to the current key and ENCRYPTION_KEY to a new one (openssl rand -base64 32), then deploy. Values encrypted with the old key still decrypt.
  2. Encrypt every stored value again with the new key: a command in the code (deployed like any change) that decrypts and encrypts each value for which isEncryptedWithPreviousKey is true.
  3. Remove FALLBACK_ENCRYPTION_KEY and deploy.

Not included

Everything the starter does is protected as described above. Some protections only make sense for features it doesn't have, so they are not included; docs/conventions/09-security.md marks them Not included and describes how to build each one when you add the feature:

  • a captcha on your own public forms (the starter has none: every action needs a signed-in member, and sign-up is Clerk's, protected above);
  • protection against calls to addresses typed by users (SSRF), for features that fetch a URL a user gives;
  • safe file uploads and outbound email HTML;
  • safe CSV exports and links typed by users.

When you add a feature that calls an address typed by a user, or that stores a secret, read docs/conventions/09-security.md §4 and §6 first. Your AI assistant does it on its own when it follows the rules.

Before your first real customer

Work through this list once, in order, for your production server. When every item is done, nothing else is required to run safely. Deployment itself is in Deploy; each step links to its details.

Keys and secrets

  1. Production keys only. Production uses a Clerk production instance (pk_live_..., sk_live_... and its own JWT public key) and a live Stripe restricted key (rk_live_...). Development instances, sandboxes and their keys stay on your machine.
  2. Rotate every test key that left a safe place. A key pasted in a chat with an AI assistant, a ticket, a screenshot or a commit is public: create a new one in the Clerk or Stripe dashboard and delete the old one. Do it for the development keys too.
  3. Generate the production secrets on the server: POSTGRES_PASSWORD with openssl rand -hex 32 (before the first deploy) and ENCRYPTION_KEY with openssl rand -base64 32. Keep a copy of both in your password manager: without the encryption key, the encrypted values of a restored backup can't be read. .env.production is readable by you only (chmod 600) and never committed.

Sign-in (Clerk)

  1. Clerk production instance created from your development one, its DNS records verified, Organizations on with Membership required, the application name set to your product's name, and Google or other social sign-in using your own OAuth credentials (Deploy, "Sign-in: a Clerk production instance").
  2. Bot protection on the production instance: Protect → Rules → Bot sign-up protection says Enabled; the lockout policy is on; disposable email addresses are blocked (Authentication, "Protect sign-up against bots").
  3. Clerk webhook https://<API_DOMAIN>/webhooks/clerk with its 7 events and its signing secret in CLERK_WEBHOOK_SIGNING_SECRET, so deleted people, members and workspaces reach your data and deleted workspaces stop paying.

Payments (Stripe), before taking real payments

  1. Account activated, with business details, public name, support email and branding (Billing, "Going live").
  2. Restricted live key with write access to Customers, Checkout Sessions, Customer portal, Subscriptions, Products and Prices, and read access to Invoices, Payment Disputes, and Charges and Refunds, in STRIPE_SECRET_KEY; BILLING_PROVIDER=stripe.
  3. Webhook endpoint https://<API_DOMAIN>/webhooks/stripe, API version 2026-08-26.dahlia, the 18 events listed in Billing, and its signing secret in STRIPE_WEBHOOK_SECRET.
  4. Catalog in live mode: bash packages/app-utils/prod-compose.sh run --rm billing-sync-catalog (it passes --live for you), again after every change of the plans.
  5. Tax: your tax registration in Stripe, then automatic tax turned on in the code (Billing, "Tax"); confirmed with an accountant.
  6. Payment settings: payment methods chosen, Smart Retries consistent with BILLING_PAST_DUE_GRACE_PERIOD_DAYS, customer emails on (receipts, failed payments), and your choice for Manage disputed payments (Billing, "Disputes, refunds and fraud warnings").

Server, domains and HTTPS

  1. Domains: the three addresses (APP_DOMAIN, API_DOMAIN, WEBSITE_DOMAIN) point at the server, and each opens over https:// with a valid certificate.
  2. Firewall: only ports 22, 80 and 443 open. Postgres and Redis have no public port (the stack never publishes them).
  3. Server kept up to date: automatic security updates on (on Ubuntu or Debian, the unattended-upgrades package, already on in most providers' images), and SSH sign-in with a key only, password sign-in off (Deploy, "Choose a server").
  4. Clock synced: timedatectl says System clock synchronized: yes (sign-in tokens live 60 seconds).
  5. Health: bash packages/app-utils/prod-compose.sh ps --all shows migrate exited with 0, api, postgres, redis, web, website and backup healthy, worker and edge running.

Backups

  1. Copied off the server: the backups folder is copied to another place regularly, for example with rsync from your computer (Deploy, "Copy the backups off the server"), and kept private.
  2. Watched: a heartbeat monitor address in BACKUP_HEARTBEAT_URL, so you get an email when a night passes without a backup.
  3. One restore tested, in localhost mode on your computer.

Alerts

  1. Error alerts on: EXCEPTION_HANDLER_DRIVER=sentry with SENTRY_DSN, and VITE_SENTRY_DSN for the web app; Sentry's alert rules email you new issues of every level (payment warnings included); the emails reach someone who reads them (Deploy, "Error alerts").

Habits

  1. AI assistants get read-only database access, if any. Production changes happen only through reviewed code: a data fix is a command or a migration, deployed like any change.
  2. Review the lint exemptions in packages/app-server/.oxlintrc.json whenever you add one: each must have a true reason.
  3. Website: its placeholder contact email and legal links replaced (packages/app-website/README.md).

Warning: Never fix production by hand: no manual SQL, no one-off script, no hand-edited configuration other than .env.production. Fix the code, merge it, and let the normal deploy ship it. Read-only checks are fine.