Security Reference¶
Consolidated reference for how Changemaker Lite handles authentication, authorization, secrets, and rate limiting — plus a summary of the security audits the platform has been through.
Scope
This page is a reference, not a setup guide. For deploy-time security checklist items, see the Production Checklist. For per-variable secret generation, see Environment Variables → Generating Secrets.
Authentication¶
Token Model¶
| Token | Lifetime | Storage | Purpose |
|---|---|---|---|
| Access token (JWT) | 15 min | Memory / Zustand store | Sent as Authorization: Bearer on every request |
| Refresh token (JWT) | 7 days | DB row + httpOnly cookie | Used at /api/auth/refresh to mint a new access token |
| Invite token (JWT) | Configurable | Signed with separate secret | Volunteer invitations, single-use |
Refresh tokens are rotated atomically on every refresh call — the Prisma transaction deletes the old token and creates a new one in one step, preventing replay attacks if a refresh token leaks.
JWT Secrets¶
Three separate secrets are required — sharing one across purposes defeats the separation:
| Variable | Algorithm | Generate with |
|---|---|---|
JWT_ACCESS_SECRET |
HS256 | openssl rand -hex 32 |
JWT_REFRESH_SECRET |
HS256 | openssl rand -hex 32 (must differ) |
JWT_INVITE_SECRET |
HS256 | openssl rand -hex 32 (must differ) |
The JWT algorithm is locked to HS256 at verification time — tokens signed with any other algorithm (including none) are rejected. This mitigates the classic alg: none bypass.
Password Policy¶
Schema-enforced at the API level:
- Minimum 12 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
Applies to initial admin creation, registration, password resets, and any user-modifies-password path. Passwords are hashed with bcrypt (cost factor 12) before storage.
User Enumeration Prevention¶
Auth endpoints (/login, /register, /forgot-password) return generic success or 401 responses that don't reveal whether an account exists. A 401 from /api/auth/me does not confirm the user exists.
Authorization (RBAC)¶
11 roles, with SUPER_ADMIN implicitly bypassing all role checks:
| Role | Scope |
|---|---|
SUPER_ADMIN |
Full platform access |
INFLUENCE_ADMIN |
Campaigns, responses, representatives, email queue |
MAP_ADMIN |
Locations, areas, shifts, canvassing |
BROADCAST_ADMIN |
Newsletter sync, email templates |
CONTENT_ADMIN |
Landing pages, homepage, navigation, documentation |
MEDIA_ADMIN |
Video library, analytics, gallery, moderation, ads |
PAYMENTS_ADMIN |
Products, donations, plans, Stripe configuration |
EVENTS_ADMIN |
Ticketed events, check-in, Gancio sync |
SOCIAL_ADMIN |
Social connections, achievements, spotlights, referrals |
USER |
Volunteer portal only |
TEMP |
Auto-created on public shift signup; blocked from writes by requireNonTemp |
Middleware primitives:
authenticate— requires a valid JWTrequireRole(...roles)— checksreq.user.roleagainst an allow-listrequireNonTemp— blocksTEMPusers from non-signup endpointsoptionalAuth— identifies the user if a token is present, but doesn't require one
User-management writes additionally require permissions.canManageUsers: true unless the actor is SUPER_ADMIN.
Encryption at Rest¶
ENCRYPTION_KEY (a 64-char hex string — openssl rand -hex 32) is used to AES-encrypt secrets stored in the database: SMTP passwords, third-party API keys (Stripe, MaxMind, etc.), and OAuth tokens.
Key separation is required — ENCRYPTION_KEY must differ from all three JWT secrets. The API refuses to start if any JWT secret equals the encryption key.
Two additional separation-of-concerns secrets (fall back to JWT_ACCESS_SECRET with a loud warning if empty):
| Variable | Purpose |
|---|---|
GITEA_SSO_SECRET |
Signs Gitea SSO session cookies |
SERVICE_PASSWORD_SALT |
Derives per-user passwords for provisioned services (Gitea, Rocket.Chat) |
See Environment Variables → Security Extras for details.
Rate Limiting¶
Redis-backed, keyed by IP address. Auth endpoints are the strictest:
| Endpoint group | Limit | Redis prefix |
|---|---|---|
Auth (/login, /register, /refresh) |
10 / 15 min | rl:auth: |
| Email sending | 30 / hour | rl:email: |
| Response submission | 10 / hour | rl:response: |
| Shift signup | 10 / hour | rl:shift-signup: |
| Canvass visits | 30 / min | rl:canvass-visit: |
| Canvass bulk visits | 5 / min | rl:canvass-visit-bulk: |
| GPS tracking ingest | 6 / min | rl:gps-tracking: |
| Global (everything else) | RATE_LIMIT_MAX / window |
rl:global: |
Defaults are tunable via RATE_LIMIT_WINDOW_MS (default 15 min) and RATE_LIMIT_MAX (default 500).
Nginx applies a second layer of rate limiting on /api/auth/* in front of the API — see Security Audits for the relevant audit that added this.
See the API Reference → Rate Limits for the full table.
Cookies & Session¶
- Refresh tokens are stored as
httpOnly,Secure(whenNODE_ENV=production),SameSite=Laxcookies — unreachable from JavaScript. - Access tokens are not persisted; Zustand holds them in memory. Page refresh triggers a silent refresh-token exchange.
- Ban enforcement: banned users have all refresh tokens invalidated immediately at the DB level — their next refresh fails and they're logged out globally.
Input Validation & Injection Defenses¶
| Vector | Defense |
|---|---|
| SQL injection | Prisma parameterizes every query; no raw SQL in route handlers |
| XSS (stored) | escape-html on user-supplied text before DB insert; DOMPurify on rich content |
| SSTI | Handlebars templates run with noEscape: false (default escaping on) |
| Path traversal | path.resolve + startsWith check against the allowed root for all file-serving routes |
| Open redirect | Redirect ?next= params validated against an allow-list of same-origin paths |
| CSV injection | Leading =, +, -, @ characters in exported cells are prefixed with ' |
| QR payload DoS | /api/qr enforces a hard cap on input text length |
Request bodies are validated with Zod schemas before reaching the service layer. Invalid requests return 400 VALIDATION_ERROR with the failing path.
Nginx & Transport¶
Nginx adds the following security headers to all responses:
Strict-Transport-Security(HSTS) with long max-agePermissions-Policyrestricting browser feature accessContent-Security-Policywithframe-ancestorsconfigured for legit embed targetsX-Forwarded-For/X-Real-IPset so the API sees the original client IP (important for rate limiting)- Hides the nginx version banner
SSL/TLS termination is handled by the tunnel provider (Pangolin or Cloudflare) — nginx itself listens on plain HTTP inside the Docker network.
Redis & Database¶
- Redis authentication is mandatory —
REDIS_PASSWORDhas no default and the API refuses to connect without it. - MongoDB (used by Rocket.Chat) runs with
--authand a keyfile mounted read-only; replica-set auth is required for every connection. - The PostgreSQL instance accepts connections only from the Docker bridge network — no host port is exposed for the container's internal
5432(host maps to5433for developer tooling only).
Security Audits¶
Four external/internal security audits have been performed on the platform.
| Date | Scope | Findings | Full Report |
|---|---|---|---|
| Feb 11 2025 | Initial audit — auth, sessions, tokens, XSS | 13 | SECURITY_AUDIT_2025-02-11.md (repo root) |
| Mar 22 2026 | JWT hardening, webhook auth, CSV injection, QR DoS | — | Internal |
| Mar 27 2026 | IDOR, XSS, path traversal, MongoDB auth, SSTI, open redirect | 33 (30 fixed) | Internal |
| Mar 30 2026 | IDOR in action items/ticketed events, nginx rate limit, JWT secret reuse | 19 | Internal |
Audit-driven changes already in the codebase include: JWT algorithm lockdown, separate invite/refresh secrets, refresh-token rotation, user enumeration prevention, the encryption-key separation requirement, nginx auth rate limiting, MongoDB keyfile auth, and the input-validation defenses listed above.
Reporting a vulnerability
Security-sensitive reports should go to admin@bnkops.ca directly rather than a public Gitea issue. Please include a minimal reproduction and the affected version.
Related¶
- Production Checklist — pre-launch hardening items
- Environment Variables — every secret-bearing variable with generation commands
- API Reference → Authentication — token flow diagram and role table
- Admin → People & Access — operational user management