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 requiredENCRYPTION_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.