Security & Hardening
Security & Hardening
What it does
Rate limiting (better-auth's own endpoints, plus our own Server Actions), security response headers (CSP report-only, HSTS, frame/referrer/permissions policy), optional Cloudflare Turnstile bot-checks on signup and the contact form, and an admin-visible audit log of sensitive account/org/billing actions.
How to configure it
| Var | Purpose |
|---|---|
NEXT_PUBLIC_TURNSTILE_SITE_KEY | Optional. Renders the Turnstile widget on signup/contact when set. |
TURNSTILE_SECRET_KEY | Optional. Server-side verification. Both unset = no captcha check anywhere, forms still work. |
Rate limiting and security headers need no configuration — they're on by default. Create Turnstile keys at the Cloudflare dashboard (the "invisible"/"managed" widget type works well here) and set both vars to turn bot-checking on.
Deploying behind a reverse proxy
If anything sits in front of Node — Coolify/Traefik, Cloudflare, a load
balancer — set TRUSTED_PROXIES. Both rate limiters resolve the client IP
through better-auth's getIp, which fails closed: an
x-forwarded-for carrying more than one hop resolves to null unless
trustedProxies lets it walk the chain back past your own hops
(@better-auth/core/utils/ip). getClientIp() turns that null into the
literal string "unknown", so the limiter still works — it just buckets
every visitor together, and one abusive client locks out the whole
site. Nothing errors; you'd only notice from support tickets.
| Var | Value |
|---|---|
TRUSTED_PROXIES | Comma-separated IPs/CIDRs of your own proxy hops. Coolify/Traefik: the Docker network range, e.g. 10.0.0.0/8,172.16.0.0/12. |
IP_ADDRESS_HEADERS | Comma-separated, in priority order. Unset → x-forwarded-for. Behind Cloudflare, prefer cf-connecting-ip. |
Both feed advanced.ipAddress in lib/auth.ts, and lib/rate-limit.ts's
getClientIp() reads the same config — so both limiters stay consistent
with whatever trust boundary you set. Leave them unset when Node is reached
directly; trusting a forwarded header with no proxy in front makes the
client IP trivially spoofable.
How it works
- Rate limiting — two separate limiters, two separate tables, because
they sit on two separate request paths:
- better-auth's own built-in limiter (
rateLimitoption inlib/auth.ts) covers every better-auth endpoint: sign-in, sign-up, password reset,/subscription/upgrade, etc.storage: "database"persists it to therateLimittable (lib/db/schema/auth.ts— hand- written to match the plugin's expected field names, same@better-auth/cli generatelimitation asorganization.md) instead of the default in-memory store, so limits survive restarts/serverless cold starts and are shared across instances.enabled: trueturns it on outside production too (it's prod-only by default). Sign-in/sign-up/ password-reset already get sensible built-in defaults (3 requests/10s — seenode_modules/better-auth/dist/api/rate-limiter/index.mjs'sgetDefaultSpecialRules);customRulesadds one more for/subscription/upgrade(10/hour — the checkout entry point). lib/rate-limit.ts'slimit()is for our own Server Actions, which never go through better-auth's request pipeline. Backed by its ownactionRateLimitstable (lib/db/schema/app.ts). Currently used bylib/actions/contact.ts(5 submissions/hour per IP). Same window semantics as better-auth's own limiter (mirrorsdecideConsume): a bucket resets oncewindowMshas passed since its last counted request; once it hitsmax, further attempts within the window are rejected without extending the cooldown. Implemented asSELECT ... FOR UPDATE+ a decide-in-JS branch inside a transaction, not a singleINSERT ... ON CONFLICT DO UPDATE— an UPDATE'sRETURNINGclause only ever sees the new row, so a self-referencingCASEcan't tell "count reached max because this request pushed it there" (allowed) apart from "count was already at max" (blocked); locking the row and branching explicitly makes that distinction possible while staying race-safe.getClientIp(headers)wraps better-auth's owngetIputility (@better-auth/core/utils/ip) so both limiters resolve the client IP the same way and respect the sameadvanced.ipAddresstrust config — see "How to configure it" above.
- better-auth's own built-in limiter (
- Security headers —
next.config.ts'sheaders()— applies to every route:Content-Security-Policy-Report-Only(logs violations to the browser console without blocking anything — see "How to extend it" to make it enforcing),X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy(camera/mic/geolocation off), andStrict-Transport-Securityin production only (meaningless overnext dev's plain http). The CSP allowshttps://challenges.cloudflare.cominscript-src/connect-src/frame-srcfor Turnstile — drop those if you remove it. - Bot protection — two different mechanisms for two different call
sites:
- Signup goes through better-auth's own
captchaplugin (lib/auth.ts, providercloudflare-turnstile), scoped to just/sign-up/emailviaendpoints: ["/sign-up/email"](its default also covers sign-in and password-reset, which don't have the widget on their forms — leaving those in would 400 every login once a secret key is set). Only added to thepluginsarray whenTURNSTILE_SECRET_KEYis set — the plugin throws on a protected request with no secret key configured, so "unconfigured = skip" has to mean "not in the array", not "no-op inside the plugin". - Contact isn't a better-auth endpoint, so it can't use the plugin —
lib/turnstile.ts'sverifyTurnstileToken()calls Cloudflare'ssiteverifyAPI directly fromlib/actions/contact.ts. Same graceful-degradation contract aslib/email.ts/lib/stripe.ts(isTurnstileConfiguredfalse ⇒ skip, don't block real users behind an unconfigured captcha) — except unlike email, a verification failure once it is configured fails closed (rejects the submission) rather than best-effort, since this gates a write. components/auth/turnstile-widget.tsx— the client-side widget wrapper shared by both forms. Hand-rolled (a<Script>+ a few lines againstwindow.turnstile) rather than an npm dependency, matching this repo's existing dependency-light conventions. Only ever mounted whenNEXT_PUBLIC_TURNSTILE_SITE_KEYis set.
- Signup goes through better-auth's own
- Audit log —
lib/db/schema/app.ts'sauditLogs+lib/audit.ts'slogAudit()(best-effort, same non-critical error handling aslib/email.ts— a logging failure must never break the action it's documenting).actionis free-form dot-namespaced text ("organization.member.role_updated"), not a DB enum, so a new call site never needs a migration. Current call sites:lib/auth.ts'sorganizationpluginorganizationHooks:afterUpdateMemberRole,afterRemoveMember(both log withactorId: null— these two hook payloads don't expose who performed the action, only who it was done to;metadata.targetUserIdrecords the latter),afterCreateInvitation,afterCancelInvitation(these do get a real actor —inviter/cancelledBy— and use it).lib/auth.ts's StripeonSubscriptionComplete/onSubscriptionCancelhooks ("billing.subscription.started"/"...cancelled"), logged right after the existingclaimStripeEventidempotency check passes — so a webhook retry doesn't double-log either.scripts/promote-admin.ts("user.role.promoted") — raw SQL, notlogAudit(), since this script is deliberately self-contained (no path aliases, no Next server context forimport "server-only"modules — see the file's own comment).actorIdis null: a CLI operator with direct DB access isn't a signed-in "actor".- What is and isn't logged: the call sites above are the complete
set. Admin auth events are covered —
banUser,unbanUser,impersonateUser, andstopImpersonatinginlib/actions/admin-users.tseach write their own row (user.banned,user.unbanned,user.impersonation_started,user.impersonation_stopped). Ordinary business mutations are not logged; add alogAudit()call to any action whose history you need.
app/(admin)/admin/audit/page.tsx— read-only table (admin-gated by the(admin)layout'srequireAdmin(), same as every other/admin/*page), newest first, left-joined touserfor the actor's email. A plain GET<form>with a<select>filters byaction(KNOWN_ACTIONSin the page — kept in sync by hand with the call sites above; an unlisted action still renders fine, it just won't appear as a filter option until added).
How to extend it
- New Server Action needing a rate limit — call
limit(key, {max, windowMs})fromlib/rate-limit.tsbefore the write; return a friendly error (seelib/actions/contact.ts) rather than throwing. Pick akeythat can't be trivially rotated by the caller (IP, not an email/name field they control). - New better-auth endpoint needing a different limit — add an entry to
rateLimit.customRulesinlib/auth.ts. - New sensitive action needing an audit entry — call
logAudit()(or, inside a better-auth plugin hook, follow theorganizationHooks/Stripe- hook pattern above) with a new dot-namespacedactionstring, and add it toKNOWN_ACTIONSinapp/(admin)/admin/audit/page.tsxso it shows up as a filter option. - Enforce the CSP instead of report-only — drop
-Report-Onlyfrom the header key innext.config.tsonce you've watched real traffic for a while and confirmed nothing legitimate trips it. If'unsafe-inline'ends up too loose, see the nonce-based approach in the Next.js CSP guide atnode_modules/next/dist/docs/01-app/02-guides/content-security-policy.md. - Add a captcha to another better-auth form (e.g. login) — add its path
to the
captchaplugin'sendpointsarray inlib/auth.ts, and mount<TurnstileWidget>on that form the same waysignup/page.tsxdoes.
How to remove it
Delete lib/rate-limit.ts + its test, lib/turnstile.ts,
components/auth/turnstile-widget.tsx, lib/audit.ts,
app/(admin)/admin/audit/, the rateLimit/actionRateLimits/auditLogs
tables from lib/db/schema/ (regenerate a migration), the rateLimit
option, captcha plugin, and organizationHooks/audit-logging calls from
lib/auth.ts, the headers() export from next.config.ts, the Turnstile
env vars, and the "Audit log" sidebar link in app/(admin)/layout.tsx.