Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

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

VarPurpose
NEXT_PUBLIC_TURNSTILE_SITE_KEYOptional. Renders the Turnstile widget on signup/contact when set.
TURNSTILE_SECRET_KEYOptional. 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.

VarValue
TRUSTED_PROXIESComma-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_HEADERSComma-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 (rateLimit option in lib/auth.ts) covers every better-auth endpoint: sign-in, sign-up, password reset, /subscription/upgrade, etc. storage: "database" persists it to the rateLimit table (lib/db/schema/auth.ts — hand- written to match the plugin's expected field names, same @better-auth/cli generate limitation as organization.md) instead of the default in-memory store, so limits survive restarts/serverless cold starts and are shared across instances. enabled: true turns 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 — see node_modules/better-auth/dist/api/rate-limiter/index.mjs's getDefaultSpecialRules); customRules adds one more for /subscription/upgrade (10/hour — the checkout entry point).
    • lib/rate-limit.ts's limit() is for our own Server Actions, which never go through better-auth's request pipeline. Backed by its own actionRateLimits table (lib/db/schema/app.ts). Currently used by lib/actions/contact.ts (5 submissions/hour per IP). Same window semantics as better-auth's own limiter (mirrors decideConsume): a bucket resets once windowMs has passed since its last counted request; once it hits max, further attempts within the window are rejected without extending the cooldown. Implemented as SELECT ... FOR UPDATE + a decide-in-JS branch inside a transaction, not a single INSERT ... ON CONFLICT DO UPDATE — an UPDATE's RETURNING clause only ever sees the new row, so a self-referencing CASE can'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 own getIp utility (@better-auth/core/utils/ip) so both limiters resolve the client IP the same way and respect the same advanced.ipAddress trust config — see "How to configure it" above.
  • Security headers — next.config.ts's headers() — 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), and Strict-Transport-Security in production only (meaningless over next dev's plain http). The CSP allows https://challenges.cloudflare.com in script-src/connect-src/frame-src for Turnstile — drop those if you remove it.
  • Bot protection — two different mechanisms for two different call sites:
    • Signup goes through better-auth's own captcha plugin (lib/auth.ts, provider cloudflare-turnstile), scoped to just /sign-up/email via endpoints: ["/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 the plugins array when TURNSTILE_SECRET_KEY is 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's verifyTurnstileToken() calls Cloudflare's siteverify API directly from lib/actions/contact.ts. Same graceful-degradation contract as lib/email.ts/lib/stripe.ts (isTurnstileConfigured false ⇒ 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 against window.turnstile) rather than an npm dependency, matching this repo's existing dependency-light conventions. Only ever mounted when NEXT_PUBLIC_TURNSTILE_SITE_KEY is set.
  • Audit log — lib/db/schema/app.ts's auditLogs + lib/audit.ts's logAudit() (best-effort, same non-critical error handling as lib/email.ts — a logging failure must never break the action it's documenting). action is 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's organization plugin organizationHooks: afterUpdateMemberRole, afterRemoveMember (both log with actorId: null — these two hook payloads don't expose who performed the action, only who it was done to; metadata.targetUserId records the latter), afterCreateInvitation, afterCancelInvitation (these do get a real actor — inviter/cancelledBy — and use it).
    • lib/auth.ts's Stripe onSubscriptionComplete/onSubscriptionCancel hooks ("billing.subscription.started"/"...cancelled"), logged right after the existing claimStripeEvent idempotency check passes — so a webhook retry doesn't double-log either.
    • scripts/promote-admin.ts ("user.role.promoted") — raw SQL, not logAudit(), since this script is deliberately self-contained (no path aliases, no Next server context for import "server-only" modules — see the file's own comment). actorId is 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, and stopImpersonating in lib/actions/admin-users.ts each write their own row (user.banned, user.unbanned, user.impersonation_started, user.impersonation_stopped). Ordinary business mutations are not logged; add a logAudit() call to any action whose history you need.
  • app/(admin)/admin/audit/page.tsx — read-only table (admin-gated by the (admin) layout's requireAdmin(), same as every other /admin/* page), newest first, left-joined to user for the actor's email. A plain GET <form> with a <select> filters by action (KNOWN_ACTIONS in 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}) from lib/rate-limit.ts before the write; return a friendly error (see lib/actions/contact.ts) rather than throwing. Pick a key that 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.customRules in lib/auth.ts.
  • New sensitive action needing an audit entry — call logAudit() (or, inside a better-auth plugin hook, follow the organizationHooks/Stripe- hook pattern above) with a new dot-namespaced action string, and add it to KNOWN_ACTIONS in app/(admin)/admin/audit/page.tsx so it shows up as a filter option.
  • Enforce the CSP instead of report-only — drop -Report-Only from the header key in next.config.ts once 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 at node_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 captcha plugin's endpoints array in lib/auth.ts, and mount <TurnstileWidget> on that form the same way signup/page.tsx does.

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.