Skip to content

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 JWT
  • requireRole(...roles) — checks req.user.role against an allow-list
  • requireNonTemp — blocks TEMP users from non-signup endpoints
  • optionalAuth — 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 (when NODE_ENV=production), SameSite=Lax cookies — 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-age
  • Permissions-Policy restricting browser feature access
  • Content-Security-Policy with frame-ancestors configured for legit embed targets
  • X-Forwarded-For / X-Real-IP set 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_PASSWORD has no default and the API refuses to connect without it.
  • MongoDB (used by Rocket.Chat) runs with --auth and 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 to 5433 for 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.