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-guardedandapp/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
PublicEndpointGuardandNoPermissionGuard, so they are visible in review. Today two endpoints are public: the health check (GET /healthz) and theappInfoGraphQL 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 getsFORBIDDEN.
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
workspaceIdfirst and adds it to the query itself. It refuses aworkspaceIdwritten 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
workspaceIdalways comes from the signed-in member, never from what the browser sends. Inputs don't even have aworkspaceIdfield. - 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, inpackages/app-server/.oxlintrc.json. - Tests prove it. The API tests check that a person from another workspace gets
NOT_FOUNDfor 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/stripeandPOST /webhooks/clerkcheck 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.
| Action | Counted per | Allowed |
|---|---|---|
| Opening Stripe Checkout, opening the billing portal | workspace | 10 per minute each |
| Renaming the workspace | workspace | 10 per minute |
| Creating a project | person | 30 per minute |
| Stripe and Clerk webhooks | caller's IP address | 600 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 isworkspace,userorip. 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=falseturns 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:
| Setting | Default | What it limits |
|---|---|---|
GRAPHQL_MAX_DEPTH | 10 | how deeply fields are nested |
GRAPHQL_MAX_ALIASES | 15 | aliases, which ask for the same field many times |
GRAPHQL_MAX_DIRECTIVES | 50 | directives such as @include |
GRAPHQL_MAX_TOKENS | 1000 | the length of the request (words and symbols), checked while reading it |
GRAPHQL_MAX_COST | 5000 | an 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 validENCRYPTION_KEY. - Code reads configuration through a typed service, never from
process.envdirectly.
Secrets stay out of git
.envfiles are ignored by git. The committed.env.examplefiles 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 anEncryptedString. 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.shwrites one into a newpackages/app-server/.env.
In your feature module, import SecretEncryptionModule, then:
// 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.
- In
.env.production, setFALLBACK_ENCRYPTION_KEYto the current key andENCRYPTION_KEYto a new one (openssl rand -base64 32), then deploy. Values encrypted with the old key still decrypt. - 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
isEncryptedWithPreviousKeyis true. - Remove
FALLBACK_ENCRYPTION_KEYand 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
- 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. - 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.
- Generate the production secrets on the server:
POSTGRES_PASSWORDwithopenssl rand -hex 32(before the first deploy) andENCRYPTION_KEYwithopenssl 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.productionis readable by you only (chmod 600) and never committed.
Sign-in (Clerk)
- 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").
- 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").
- Clerk webhook
https://<API_DOMAIN>/webhooks/clerkwith its 7 events and its signing secret inCLERK_WEBHOOK_SIGNING_SECRET, so deleted people, members and workspaces reach your data and deleted workspaces stop paying.
Payments (Stripe), before taking real payments
- Account activated, with business details, public name, support email and branding (Billing, "Going live").
- 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. - Webhook endpoint
https://<API_DOMAIN>/webhooks/stripe, API version2026-08-26.dahlia, the 18 events listed in Billing, and its signing secret inSTRIPE_WEBHOOK_SECRET. - Catalog in live mode:
bash packages/app-utils/prod-compose.sh run --rm billing-sync-catalog(it passes--livefor you), again after every change of the plans. - Tax: your tax registration in Stripe, then automatic tax turned on in the code (Billing, "Tax"); confirmed with an accountant.
- 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
- Domains: the three addresses (
APP_DOMAIN,API_DOMAIN,WEBSITE_DOMAIN) point at the server, and each opens overhttps://with a valid certificate. - Firewall: only ports 22, 80 and 443 open. Postgres and Redis have no public port (the stack never publishes them).
- Server kept up to date: automatic security updates on (on Ubuntu or Debian, the
unattended-upgradespackage, already on in most providers' images), and SSH sign-in with a key only, password sign-in off (Deploy, "Choose a server"). - Clock synced:
timedatectlsaysSystem clock synchronized: yes(sign-in tokens live 60 seconds). - Health:
bash packages/app-utils/prod-compose.sh ps --allshowsmigrateexited with 0,api,postgres,redis,web,websiteandbackuphealthy,workerandedgerunning.
Backups
- Copied off the server: the
backupsfolder is copied to another place regularly, for example withrsyncfrom your computer (Deploy, "Copy the backups off the server"), and kept private. - Watched: a heartbeat monitor address in
BACKUP_HEARTBEAT_URL, so you get an email when a night passes without a backup. - One restore tested, in localhost mode on your computer.
Alerts
- Error alerts on:
EXCEPTION_HANDLER_DRIVER=sentrywithSENTRY_DSN, andVITE_SENTRY_DSNfor 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
- 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.
- Review the lint exemptions in
packages/app-server/.oxlintrc.jsonwhenever you add one: each must have a true reason. - 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.