---
title: Security Reference
description: Password policy, rate limits, token model, encryption, RBAC, and audit history.
icon: material/lock-check
tags:
  - reference
  - operator
  - security
search:
  boost: 2
---

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

!!! info "Scope"
    This page is a reference, not a setup guide. For deploy-time security checklist items, see the [Production Checklist](index.md#production-checklist). For per-variable secret generation, see [Environment Variables → Generating Secrets](../getting-started/environment-variables.md#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](../getting-started/environment-variables.md#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](#security-audits) for the relevant audit that added this.

See the [API Reference → Rate Limits](../api/index.md#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.

!!! note "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](index.md#production-checklist) — pre-launch hardening items
- [Environment Variables](../getting-started/environment-variables.md) — every secret-bearing variable with generation commands
- [API Reference → Authentication](../api/index.md#authentication) — token flow diagram and role table
- [Admin → People & Access](../admin/people-access.md) — operational user management
