Security Model
This page describes the request-level defenses that sit in front of every page and API route: the authentication middleware, security headers, Content-Security-Policy, and rate limiting. For login/OTP specifics see Login-and-Two-Step-Verification; for role checks see Roles-and-Permissions; for data-at-rest protection see Data-Encryption.
Using it
Section titled “Using it”There is nothing for day-to-day staff to configure here — these protections are always on. The one operationally-relevant behaviour to know about:
- If the app is ever deployed behind more than one running instance, the in-memory rate limiter (used for a handful of lower-stakes public endpoints) stops being fully effective, because each instance keeps its own counters. The app logs a boot warning if it detects a replica count above 1. Self-hosters running a single instance are unaffected.
- Every response carries a strict cross-origin policy. If you embed a ParishCRM page inside
another site (e.g. an iframe), it will refuse to render there by design —
frame-ancestorsis set to deny all framing.
How it works
Section titled “How it works”Middleware runs on every route. src/middleware.ts runs on effectively every request
(static assets excluded) rather than only on protected routes, because it also needs to
apply a per-request Content-Security-Policy nonce everywhere, including public pages. It does
three things in order: check a site-wide maintenance flag, enforce authentication, and then
apply security headers.
Edge vs. Node split. NextAuth’s config is split in two: src/auth.config.ts is
Edge-runtime-safe (no database driver, no bcrypt) and is what middleware.ts uses for JWT
validation; src/auth.ts is the full Node.js config with database access, used everywhere
else (Server Components, Server Actions, the credentials provider itself). Middleware can
only read and validate the JWT — it never touches the database on the hot path, and account-
state checks (locked, deleted, idle-timeout) happen inside the Node.js jwtCallback, which
also runs on every authenticated request via Server Components.
Authentication gate. Any request without a valid session is redirected to /login,
unless the path matches an explicit public-path allowlist (src/lib/csp.ts,
isPublicPath). That allowlist is a short, deliberately narrow list — exact paths (login,
password reset, privacy policy, health check, PWA assets, robots.txt), a few whole
“trees” (NextAuth’s own routes, public branding assets, the membership application form),
and a small number of tightly-scoped regular expressions for specific public event pages,
specific public event API endpoints, and the token-scoped family self-update form. New public
sub-routes are never automatically public just because they share a prefix with an existing
one — each has to be added deliberately, which is intentional: it’s the difference between
“only this exact sub-path is exposed” and “any future route under here silently becomes
public.”
Trailing-slash and path normalization. The public-path check strips a trailing slash
before matching, so /login/ can’t slip past the allowlist as a distinct string that neither
matches nor requires auth. Any path that doesn’t cleanly match a known public pattern stays
gated — the check fails closed.
Role confinement. After the authentication gate, one additional per-role check runs in
middleware: an EVENT_ORGANISER session outside its allowed path set is redirected to its
own home page. See Roles-and-Permissions for the full role model — every other role’s
page-level and action-level permissions are enforced deeper in the app (layouts, Server
Actions), not in middleware.
Security headers, applied to every dynamic response (src/lib/csp.ts,
SECURITY_HEADERS):
| Header | Effect |
|---|---|
Strict-Transport-Security |
Forces HTTPS for two years, including subdomains, and is preload-eligible. |
X-Content-Type-Options: nosniff |
Stops the browser from guessing content types. |
X-Frame-Options: DENY |
Refuses to be framed by any site (belt-and-suspenders alongside CSP frame-ancestors). |
Referrer-Policy |
Limits what’s sent in the Referer header on cross-origin navigation. |
Permissions-Policy |
Camera access restricted to same-origin only (used by the event check-in QR scanner); microphone and geolocation fully disabled. |
Cross-Origin-Opener-Policy: same-origin |
Prevents a cross-origin page that opens this app from reaching into its window. |
Cross-Origin-Resource-Policy: same-origin |
Stops other origins from embedding this app’s responses as a subresource — relaxed to cross-origin only for the specific public event-image endpoint, which an external site’s calendar embeds. |
Content-Security-Policy. Built per-request with a fresh random nonce
(src/lib/csp.ts::buildCsp). Scripts are restricted to same-origin plus the nonce plus
strict-dynamic (so Next.js’s own bootstrap scripts work); in production there is no
unsafe-inline or unsafe-eval for scripts. Styles use the same nonce approach in
production. object-src 'none', frame-ancestors 'none', and base-uri/form-action
pinned to same-origin close off the classic injection escape hatches. The Cloudflare
Turnstile CAPTCHA origin is allow-listed narrowly (script, frame, and connect) when
Turnstile is configured; it’s inert otherwise.
Rate limiting. Two limiter implementations exist, used for different purposes:
- A database-backed fixed-window limiter (
src/lib/dbRateLimit.ts) is used for security-sensitive, low-volume paths — login attempts (per source IP) and password-reset requests (per target email and per source IP). Being database-backed, it stays correct regardless of how many app instances are running. - An in-memory fixed-window limiter (
src/lib/rateLimit.ts) is used for a few higher-volume public paths where exact cross-instance correctness matters less. It is only fully effective at a single running instance, which is why the app is designed to run that way and warns on boot if it detects otherwise.
Both limiters use a fixed time window rather than a sliding one, which allows a modest burst right at a window boundary — an accepted tradeoff at parish scale, backstopped by the account-level lockout described in Login-and-Two-Step-Verification.
Maintenance mode. A database-backed flag can put the entire app behind a self-contained
503 page (with a short cache so the check itself doesn’t hammer the database), used around
deployments. It intentionally bypasses nobody — not even an admin — except a small set of
health-check and static-asset paths needed to keep the deploy pipeline and the maintenance
page itself working, plus three signature- or bearer-secret-gated endpoints that don’t use
session auth: /api/stripe/webhook, /api/cron/send-reminders and /api/cron/sweep-checkouts.
Configuration
Section titled “Configuration”| Variable | Purpose |
|---|---|
AUTH_SECRET |
Signs session tokens; see Login-and-Two-Step-Verification. |
AUTH_URL |
Must be the real https:// origin in production — session cookies are only marked Secure when the app detects a production environment, and NextAuth uses this URL to reason about the deployment origin. |
CONTAINER_APP_REPLICA_COUNT |
Set by the hosting platform, not by hand. If greater than 1, a boot warning fires because the in-memory rate limiter is only fully correct at a single instance. |
No CSP or security-header values are environment-configurable — they’re fixed in code so a misconfiguration can’t accidentally weaken them.