Security
Five layers on every request: edge headers, Better Auth, oRPC middleware, Upstash abuse policy, and Zod. Most run by default. Upstash and Turnstile need env vars. Defaults, limits, and a pre-launch checklist.
Last updated on
12 min readOn This Page
Defense In Depth
| Layer | Where it lives | What it blocks | Dev escape |
|---|---|---|---|
| Edge headers + CORS | apps/web/proxy.ts, apps/web/lib/security-headers.ts | Clickjacking, mixed content, wrong-origin API calls | None |
| Better Auth (session, rate limit, captcha) | packages/auth/src/server.ts | Missing sessions; auth-endpoint flooding; bots when Turnstile is set | Rate limits widen under NODE_ENV=test. Captcha stays off until both Turnstile env vars are set |
| oRPC middleware (auth, RBAC, admin) | packages/api/src/middleware/ | Unsigned calls, org id-swaps, missing permissions | None |
| Abuse policy (Upstash) | packages/shared/src/abuse.ts | Flooded contact, auth email, chat, uploads, billing sessions | DISABLE_ABUSE_PROTECTION=true outside production. Also auto-bypassed outside production when Upstash is unset |
| Zod input validation | Per oRPC procedure | Malformed or oversized payloads | None |
Edge: Headers, CSP, CORS
The proxy at apps/web/proxy.ts runs before every route handler. It applies a Nosecone header bundle (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Cross-Origin-Opener-Policy, Permissions-Policy) and adds CORS to API routes. The CSP allow-list extends Nosecone's defaults with only the origins the kit talks to:
| Directive | Allowance |
|---|---|
script-src | 'self', 'unsafe-inline' (Next.js bootstrap), https://challenges.cloudflare.com, https://cdn.jsdelivr.net (Scalar API reference). Non-production also allows 'unsafe-eval' for Fast Refresh |
style-src | Nosecone defaults plus 'unsafe-inline' |
img-src | 'self', data:, blob:, configured S3 / image origins, plus https://models.dev (AI model logos) |
connect-src | 'self', Turnstile, configured S3 origins (presigned PUT targets) |
frame-src | https://challenges.cloudflare.com only (Turnstile widget) |
worker-src | Nosecone defaults plus blob: |
upgrade-insecure-requests | Enabled in production |
CORS is scoped to NEXT_PUBLIC_APP_URL and applies only to /api/*, /rpc/*, and /trpc/*. Other paths get no Access-Control-Allow-Origin. Credentials are allowed so authenticated cross-origin clients can reach the API.
The proxy gate on /dashboard/* and /api-reference/* is a cheap cookie-presence check (getSessionCookie(request)), not a full session decrypt. It only redirects unauthenticated traffic fast. The real session check runs inside Better Auth handlers and oRPC authorized middleware. /api-reference adds another layer: its route handler decrypts the session, requires the platform admin role, and 404s everyone else (including signed-in non-admins) so the route stays undisclosed. It serves the docs UI and spec.json only, not a callable REST API.
Authentication: Sessions And Captcha
Better Auth issues opaque session cookies signed and encrypted by BETTER_AUTH_SECRET. httpOnly + sameSite=lax, plus a session check at every request boundary, mitigate CSRF without a separate token. Captcha is opt-in: Cloudflare Turnstile attaches only when both TURNSTILE_SECRET_KEY and NEXT_PUBLIC_TURNSTILE_SITE_KEY are set.
Session cookie hardening
Cookie attributes (httpOnly: true, sameSite: "lax", path: "/", secure: true in production) are set explicitly in packages/auth/src/server.ts via advanced.defaultCookieAttributes and advanced.useSecureCookies. They match Better Auth's defaults today; writing them out stops a future upstream change from silently weakening the kit.
trustedOrigins comes from getTrustedOrigins(): NEXT_PUBLIC_APP_URL, BETTER_AUTH_URL when set, plus any AUTH_TRUSTED_ORIGINS entries (including preview wildcards like https://*.vercel.app). OAuth callbacks and password-reset returns can only land on those origins.
session.cookieCache is on with a 60-second TTL so the common "is this user signed in?" check can answer from the signed cookie without a database round-trip. Dashboard layouts pass disableCookieCache: true so chrome stays in sync with the database. Direct /rpc and /api callers can see a cached session for up to 60 seconds after a ban or role change.
How Turnstile is wired
When both Turnstile env vars are set, Better Auth's captcha plugin mounts on /sign-up/email, /sign-in/email, /request-password-reset, /sign-in/social, and /send-verification-email. When they are unset, the plugin does not mount and auth forms submit without a captcha token. CaptchaProvider logs a one-line warning in non-production so the gap is visible.
The contact form always verifies a token server-side (unless DISABLE_CAPTCHA_FOR_TESTS is set) via verifyTurnstileToken in packages/api/src/lib/turnstile.ts. That helper uses fetchWithTimeout with a 5s ceiling so a slow Cloudflare response cannot hold a worker open.
See Authentication for the full session model, OAuth wiring, 2FA, and passkeys.
Built-in Auth Rate Limits
Better Auth's own rate limiter applies route-specific rules on top of a default ceiling in packages/auth/src/server.ts. These limits work without Upstash (in-memory). When Upstash is configured, the same rules use Redis customStorage so they hold across instances and cold starts. They apply per IP at the auth layer and sit next to the per-surface Upstash policy below (which keys on email, userId, or organizationId).
| Route | Limit | Window |
|---|---|---|
| default (every other auth endpoint) | 100 | 60s |
/sign-in/email | 5 | 10s |
/sign-up/email | 5 | 60s |
/request-password-reset | 3 | 60s |
/reset-password | 5 | 60s |
/two-factor/verify-totp | 3 | 10s |
/two-factor/verify-backup-code | 3 | 10s |
These limits widen automatically under NODE_ENV=test (Vitest and Playwright). There is no env var to disable them. Production cannot enter the test runtime, so credential-stuffing, reset-spam, and TOTP brute-force defenses stay on.
API: The Middleware Chain
Every oRPC procedure picks a base from the middleware chain in packages/api/src/middleware/. Five gates cover the common patterns:
| Middleware | What it asserts | When to use |
|---|---|---|
authorized | Better Auth session present; adds session and user to context. Throws UNAUTHORIZED otherwise. | Default base for any signed-in procedure. |
withActiveOrganization | Active org resolved and added to context. Throws BAD_REQUEST if none is active. | Procedures that operate on the active org implicitly. |
withOrganizationAccess | input.organizationId matches the active org. | Procedures that take an org id explicitly; blocks id-swap attacks. |
withPermission({...}) | Better Auth hasPermission passes for the resource and action. | Org-scoped permissions like { member: ["create"] } or { billing: ["view"] }. |
requireAdmin | Platform admin role. Throws FORBIDDEN otherwise. | Every procedure under the admin namespace. |
Every procedure also chains .input(zodSchema) and .output(zodSchema). Inputs are validated before the handler runs, so malformed or oversized payloads never reach domain code. See API for the chain in context.
Input hardening at the API edge
ID fields use the shared idSchema (z.string().min(1).max(64)). Passwords and TOTP codes are length-bounded. The admin listUsers query pins sortBy / filterField to safe-column enums so an attacker cannot sort by password or twoFactorSecret. User-supplied image URLs (org logos, avatars) are checked against a hostname allowlist from your S3 / CDN config plus NEXT_PUBLIC_IMAGE_HOST_ALLOWLIST. Bare S3 keys still pass through unchanged.
Abuse Protection (Upstash)
packages/shared/src/abuse.ts implements per-surface sliding-window rate limits on Upstash Redis. Each policy has one or two rules keyed by ip, email, userId, or organizationId, in a pre_verification or post_verification phase. Callers route the result through resolveAbuseDecision, which returns allow, rate_limited, or unavailable.
| Surface | Characteristics |
|---|---|
contact.submit | IP (5 / 10m) plus email (3 / 1h) |
auth.password_reset_email | IP (5 / 10m) plus email (3 / 1h) |
auth.invitation_email | userId (10 / 1h) plus email (5 / 1h) |
auth.change_email_confirmation | userId (5 / 1h) plus email (3 / 1h) |
auth.verification_email | IP (5 / 10m) plus email (3 / 1h) |
billing.session | userId (10 / 10m) |
chat.send | userId (20 / 10m) plus organizationId (100 / 10m) |
chat.regenerate | userId (10 / 10m) plus organizationId (30 / 10m) |
storage.image_presign | userId (10 / 10m) |
storage.image_finalize | userId (20 / 10m) |
rpc.request | IP (200 / 1m): global ceiling on the oRPC handler in apps/web/app/rpc/[[...rest]]/route.ts, before per-procedure surfaces |
Posture: Required In Production, Fail Closed Everywhere
One posture covers every surface so a misconfigured surface cannot silently disable the others.
| Mode | Behaviour |
|---|---|
| Production: required, fail closed | assertValidSetupEnv refuses to boot without both Upstash vars. Surfaces return SERVICE_UNAVAILABLE when the policy cannot decide. |
| Non-production: auto-bypass | No Upstash means resolveAbuseDecision returns allow with bypassReason dev_no_upstash and a one-time warning, so a fresh checkout works. |
| Explicit override flag | DISABLE_ABUSE_PROTECTION=true forces a bypass (bypassReason explicit_flag) for deterministic CI. Rejected at boot in production. |
How each failure mode resolves
- Production requires Upstash.
assertValidSetupEnvrefuses to start whenNODE_ENV=productionand eitherUPSTASH_REDIS_REST_URLorUPSTASH_REDIS_REST_TOKENis missing. Without it, uploads, AI chat, and transactional email would run unprotected. missing_configfails closed in production. Storage, chat, contact, and Better Auth email handlers surfaceSERVICE_UNAVAILABLE(or throw inside Better Auth) when the policy cannot decide.missing_configauto-bypasses outside production. Freshpnpm devwithout Upstash would otherwise drop verification emails, contact, chat, and uploads with no UI signal.resolveAbuseDecisionreturns{ kind: "allow", bypassed: true, bypassReason: "dev_no_upstash" }when Upstash is missing andNODE_ENV !== "production". Callers log a one-time warning. TheNODE_ENVgate and boot doctor keep this branch out of production.DISABLE_ABUSE_PROTECTION=trueis the explicit override. Use it in CI / Playwright when Upstash is provisioned but tests need deterministic outcomes. ProducesbypassReason: "explicit_flag". Setting it withNODE_ENV=productionis rejected at boot (same pattern asENABLE_BETTER_AUTH_TEST_UTILS). At runtime the flag is also ignored in production.missing_characteristicalways fails closed. A required characteristic (for example an authenticated user id) was missing at the call site. That is a programming error, not a deploy gap. Letting it through would defeatrequired: true.
The shared helper is resolveAbuseDecision in packages/shared/src/abuse.ts. New surfaces should call enforceAbusePolicy and route the result through it instead of branching on decision.reason themselves. That keeps the posture consistent across packages.
Billing Caps: Atomic Reservation, Not Check-Then-Act
Upstash windows smooth bursts. They do not enforce per-plan billing caps (monthlyAiResponses). For those, reserveAiUsageEvent in packages/core/src/usage/quota.ts counts the active billing window and inserts an AiUsageEvent inside one prisma.$transaction, opened with pg_advisory_xact_lock(hashtextextended('ai-usage:<organizationId>', 0)). Concurrent reservations for the same org serialize, so the count + insert pair is race-free.
| Property | Behaviour |
|---|---|
| Lock scope | Per organization (hashtextextended('ai-usage:<orgId>', 0)). Cross-org calls never contend. |
| Lock duration | The fast count + insert pair only. Never held while the model is producing tokens. |
| Unlimited plans | Skip the lock and the count. Insert directly for analytics. |
| On model error | refundAiUsageReservation best-effort deletes the reservation row so a failed turn does not consume quota. |
| Custom caps | Wrap reserveAiUsageEvent (or copy the pattern) for any new billable surface. |
Chat send and regenerate call this before the stream starts. Soft UI banners should read billing entitlements and usage, not replace the reservation for enforcement.
Why atomic reservation, not check-then-act
A separate up-front check and a post-stream insert lets N concurrent requests at count = limit - 1 all pass, all bill the gateway, then all increment. Free-tier orgs overshoot the cap and paid plans pay more provider cost than they should. Folding count and insert into one advisory-locked transaction makes the reservation atomic per organization.
Webhooks: Signature Verification
The Stripe route at apps/web/app/api/webhooks/stripe/route.ts rejects a missing stripe-signature with 400, then delegates to processWebhookEvent in packages/payments/src/stripe/webhook.ts. That helper verifies the payload with stripe.webhooks.constructEvent against STRIPE_WEBHOOK_SECRET. A bad signature becomes StripeWebhookSignatureError and maps back to 400 before any handler runs:
const signature = req.headers.get("stripe-signature");
if (!signature) {
log.warn("Stripe webhook missing signature header", {
attributes: { endpoint: "/api/webhooks/stripe" },
});
return new Response("Missing stripe-signature header", { status: 400 });
}After verification, the handler claims a StripeWebhookEvent row (event-level dedupe) and dedupes side effects with OutboundEffect semantic keys. A delivered-twice Stripe webhook does not double-charge, double-email, or double-grant entitlements. See Webhooks And Async Workflows for the full idempotency story.
Uploads: The Validation Boundary
Client-side checks reject the wrong size or MIME before the presign request. They are courtesy. The real boundary is the server-side sharp re-encode during finalize: a file that decodes cleanly is an image; anything else throws and the temp object is removed.
| Check | Value |
|---|---|
| Allowed MIME types | image/jpeg, image/png, image/webp, image/gif |
| Max file size | 1 MB |
| Presigned URL TTL | 6 minutes |
| Output format | JPEG (mozjpeg, q85) by default; PNG when alpha is present |
| Output dimensions | 2048 px on the longest side, fit inside |
Final outputs are always re-encoded. Original bytes never become the served file. Temp keys live under tmp/ and are deleted on every server-observable failure path. A bucket lifecycle rule that expires tmp/ after 1 day covers abandoned uploads between presign and finalize. See Storage for the full pipeline.
Operational Secrets
Three secrets every production deploy must set, plus one header pair for proxied deployments.
| Secret | Why it matters |
|---|---|
BETTER_AUTH_SECRET | Encrypts and signs session cookies. Generate with openssl rand -base64 32 (32+ characters required). Unique per environment. |
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY | Required for multi-instance deployments. Without a shared key, replica B cannot decrypt server-action signatures from replica A, which shows up as intermittent form failures. |
STRIPE_WEBHOOK_SECRET | Verifies inbound Stripe webhooks. The production secret from the Stripe dashboard is distinct from the local CLI secret. |
For deployments behind a load balancer or reverse proxy, set TRUST_PROXY_HEADERS=true and TRUSTED_PROXY_IP_HEADERS=x-forwarded-for so Better Auth sees the real client IP for rate limits and audit. Render's blueprint sets these by default. Fly's edge-only setup does not need them.
Pre-Launch Security Checklist
Generate strong session secrets
Run openssl rand -base64 32 to generate BETTER_AUTH_SECRET. Confirm it is at least 32 characters and unique to production (not copied from dev or a teammate's machine).
Generate the server actions encryption key (multi-instance only)
If you run more than one replica of the web app, generate NEXT_SERVER_ACTIONS_ENCRYPTION_KEY with openssl rand -base64 32 and set the same value on every replica.
Configure Upstash
Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. Required in production: assertValidSetupEnv refuses to start without them, so uploads, chat, auth emails, contact, and billing session creation stay gated.
Configure Turnstile
Set TURNSTILE_SECRET_KEY and NEXT_PUBLIC_TURNSTILE_SITE_KEY so the captcha plugin attaches to auth endpoints and the contact form can verify tokens.
Set the production Stripe webhook secret
Use the secret from the production webhook endpoint in your Stripe dashboard, not the CLI secret from local testing. Then register the production webhook URL in Stripe.
Confirm dev escapes are unset in production
DISABLE_ABUSE_PROTECTION must be unset (or false) in production. It exists for CI determinism only and is rejected at boot when NODE_ENV=production. Better Auth per-route rate limits widen only under NODE_ENV=test, so there is no separate flag to check. There is no runtime env var to skip env validation: next build is detected via PHASE_PRODUCTION_BUILD from next/constants, and tests via NODE_ENV=test, so a production server always validates on boot.
Set proxy headers if behind a load balancer
If your platform sits the app behind a load balancer (Render, Kubernetes ingress, custom reverse proxy), set TRUST_PROXY_HEADERS=true and TRUSTED_PROXY_IP_HEADERS=x-forwarded-for. Vercel and Fly's edge-only setups do not need these.
Verify NEXT_PUBLIC_APP_URL matches the deployed origin
CSP origins, CORS allow-origin, and OAuth callback paths all derive from this. A mismatch breaks captcha, S3 uploads, and OAuth in non-obvious ways.
Audit OAuth callback URLs
In the GitHub and Google developer consoles, confirm the registered callback URLs match <NEXT_PUBLIC_APP_URL>/api/auth/callback/github and <NEXT_PUBLIC_APP_URL>/api/auth/callback/google for the production app.
Run setup-doctor against production env
pnpm setup:doctorRun with the production env loaded (or copy it into a temporary file and pass it via dotenvx). Every protection should report active. Fix anything the doctor flags before opening to real traffic.
Where To Go Next
Going To Production
The broader pre-launch playbook: observability, rollout, smoke tests, and on-call posture.
Authentication
Sessions, OAuth, 2FA, passkeys, and abuse throttling on auth-email flows.
Also useful: API for the oRPC middleware chain, Webhooks And Async Workflows for Stripe idempotency, and Deployment for where secrets and proxy headers are set per host.
Monitoring
Error tracking, OpenTelemetry logs, and source maps on the same PostHog project as Analytics. Enabled when NEXT_PUBLIC_POSTHOG_KEY is set; host vars default to US Cloud. Health probes for uptime monitors.
Testing
Four layers: Vitest unit and integration, opt-in Stripe live, and Playwright E2E. One command per layer. Coverage gates at 90% lines / 85% branches.
