Security
The protections below are already on. This page says where each one lives, so you know what you have and what not to remove.
Who gets in
Section titled “Who gets in”| Protection | Where |
|---|---|
Every route needs a session unless it is marked @Public() |
Auth |
| A password works only once the address is verified | Auth |
| Reset links work once, for an hour, and end every other session | Auth |
| Asking for a reset answers the same whether or not the account exists | Better Auth |
| Sign-in is limited per visitor address in production | Auth |
The session cookie is httpOnly, SameSite=Lax, and Secure on HTTPS |
Auth |
| Requests from an origin other than your web app are refused | Auth |
| People see their signed-in devices and can sign the others out | Profile settings in the web app |
| After sign-in, people are only ever sent to a page of your own app | apps/web/src/features/auth/redirect.ts |
Who sees what
Section titled “Who sees what”| Protection | Where |
|---|---|
A workspace someone is not in answers 404, the same as one that is not there |
Workspaces |
| Every query on workspace data filters by the workspace | Workspaces |
| Roles are checked in the API, whatever the screen shows | Workspaces |
| Paid features are checked in the API, whatever the screen shows | Charge for a feature |
| The web app forgets everything it loaded when someone signs out | queryClient.clear() in use-session-actions.ts |
Admins are only the verified addresses in ADMIN_EMAILS, checked on every request |
Admin panel |
| Signing in as a customer lasts an hour, shows a banner, and is recorded | Admin panel |
What comes in
Section titled “What comes in”| Protection | Where |
|---|---|
| Every request body is checked against its schema before your code runs | Architecture |
| Billing events are only accepted with Stripe’s or Polar’s signature | Billing |
| The assistant only takes the user’s messages; instructions are built on the server | AI features |
| The assistant limits the size of what it is sent and what it answers | AI features |
| The contact form has a trap for bots and takes 5 messages an hour per address | Marketing site |
What goes out
Section titled “What goes out”The web and site images send these on every answer, from their Caddyfile:
| Header | What it does |
|---|---|
Strict-Transport-Security: max-age=31536000 |
Browsers use HTTPS for the next year |
X-Content-Type-Options: nosniff |
Files are only ever what they say they are |
X-Frame-Options: DENY |
No other site can put your app in a frame |
Referrer-Policy: strict-origin-when-cross-origin |
Other sites learn your domain, not the page |
The Server header is removed. deploy:check fails if the
first three are missing.
Logs never hold a cookie, a header or a query string, where sessions and sign-in tokens live. See Logs.
Secrets and containers
Section titled “Secrets and containers”- The API refuses to start in production with the example
AUTH_SECRET, or one shorter than 32 characters. .envand.env.railwayare ignored by git and left out of every image.- The API has no public address in production. Browsers reach it through the web image, under
/api. - The API image runs as a user without root.
- The web and site images believe
X-Forwarded-Foronly from your host’s private network, so a visitor cannot pretend to be another address to get around a limit. See Other hosts.
What is left to you
Section titled “What is left to you”- Backups. Your host provides them. Restore one once before you launch.
- Two-factor sign-in and passkeys are not set up. Better Auth has plugins for both.
- A Content-Security-Policy is not set, since it depends on the scripts your product
loads. Add it to the
headerblock inapps/web/Caddyfile. - Limits are counted per copy of the API, in memory. With several copies, each counts on its own.
- A usage limit for the assistant. There is none per workspace. See AI features.
- The inboxes in
ADMIN_EMAILSare keys to every account. Protect them like one.