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.

## 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