Defense-in-depth defaults are baked in — this doc lists what's enforced and the production checklist.
src/config/env/validates every var. Process refuses to start ifDATABASE_URL,JWT_SECRET(≥32 chars),FRONTEND_URL, the email-provider key matchingEMAIL_PROVIDER, complete OAuth credential pairs, or Stripe secret/webhook/price IDs (whenBILLING_ENABLED) are missing.- Production additionally requires
QUEUES_ENABLED=trueso transactional email (password reset, verification, account events) runs through BullMQ with retries. Inline email send has no retry envelope, so a provider blip silently drops the message. - Production Valkey-backed features (queues, Valkey cache, SSE, OAuth
state) require
VALKEY_PASSWORD. - Production with
CACHE_ENABLED=true(the default) requiresCACHE_PROVIDER=valkey. JWT revocation (logout, password-reset session kill, per-jti blocklist) stores its state in the cache; the in-memory provider is per-process, so revocations would vanish on restart and never propagate across replicas. - JWT revocation checks fail open by default when the cache is
unreachable: a Valkey blip never becomes a global auth outage, and
the exposure window is bounded by the 15-minute JWT TTL. Strict
deployments set
JWT_REVOCATION_FAIL_CLOSED=trueto reject every authenticated request on cache errors instead. Either way the failure is logged asauth.jwt.revoke_check_failed/auth.jwt.revoke_user_check_failed— alert on those events. ALLOWED_ORIGINSis optional. Empty = same-origin deployment (BoringStack's default) and CORS is not mounted. When set in production, every entry must be HTTPS with no wildcards.- ESLint blocks
process.env.Xoutsidesrc/config/env/**and catches typos against the schema (env-accessplugin).
| Surface | Plugin |
|---|---|
Auth cookies set httpOnly + secure; passwords hashed with argon2id (m=19 MiB, t=2); legacy bcrypt rehashed on next login |
jwt-cookies |
| OAuth state in Valkey (not cookies); PKCE for OIDC; bounded TTL | oauth-security |
| Stripe webhooks verify signature, no parsed body before verify, idempotent | stripe-webhooks |
No PII (email, token, password, ...) in logger payloads |
structured-logging/mask-pii-fields |
No PII in audit-log metadata |
audit-log/audit-metadata-no-pii |
| Multi-step DB writes are transactional | db-transactions |
Three blocking workflows run on every push to main, every PR, and on a
weekly cron (Monday morning UTC, staggered by minute). All upload SARIF
to GitHub Code Scanning.
| Workflow | Scanner | Allowlist |
|---|---|---|
security-secrets |
gitleaks CLI (pinned by SHA) | .gitleaksignore |
security-deps |
osv-scanner + bun audit --audit-level=high |
osv-scanner.toml |
security-sast |
Semgrep — OWASP + JS packs + .semgrep/ rules |
inline // nosemgrep: |
Every accepted-risk suppression has a written reason and an ignoreUntil
date. Suppressions are temporary by default — when the date passes, CI
fails. The weekly cron exists so this surfaces even when no one pushes.
The main branch on the BoringStack monorepo enforces:
- Signed commits (configure your local signing before pushing)
- Linear history (squash-merges only)
- All security workflows blocking on PR
- No force-push, no deletion
- Conversations must resolve before merge
Repo-level settings (secret scanning, push protection, Dependabot
security updates, merge prefs) are described in the monorepo root
.github/desired-repo-settings.json. Run ./scripts/audit-repo-settings.sh
from the repo root to diff that file against the live GitHub API and print
copy-pasteable fix commands — no auto-apply.
.claude/settings.json declares the Trail of Bits and Ghost Security
plugin marketplaces. The /security-review skill at
.claude/skills/security-review.md orchestrates those generic skills and
adds BoringStack invariants:
- ACL coverage on every account-scoped table
- Stripe webhook idempotency on
stripe_event_id - Multi-tenant
accountIdscoping on every route handler - Rate limits on credential routes
- Audit-log entries on every mutation
- BullMQ jobs idempotent under retry
See AGENTS.md → "Security skill set" for the full skill reference.
-
Security headers (CSP, HSTS, X-Frame-Options, Permissions-Policy, Referrer-Policy, X-Content-Type-Options) — set by Traefik in front of the api, not by the api itself. The single source of truth is
infra/compose/compose/docker-compose.production-labels.yml. Running the api standalone (no Traefik in front) leaves it without these headers — front it with Traefik or your own reverse proxy. -
CORS — gated on
ALLOWED_ORIGINS. Empty (default) = not mounted (same-origin via Traefik path routing). Cross-origin deployments set this with HTTPS origins. -
Rate limit — per-IP via
elysia-rate-limit. Defense in depth on top of Traefik's edge rate limit. Behind a reverse proxy, setTRUST_PROXY=trueso the limiter keys on the client's IP viaX-Forwarded-For; without it, every request shares the proxy's socket IP and a single bad actor locks the whole deploy out. -
Auth — short-lived JWT (15 min) + opaque refresh session (30 days, rotated on use, hashed at rest) in HTTP-only cookies (
secure,sameSite: strictin prod). Refresh tokens are family-tracked: presenting an already-rotated token revokes the entire rotation chain and writes anauth.refresh_replayaudit event, so a leaked token can be reused at most once before the family is killed. Verification and reset tokens are opaque, hashed at rest, bounded by TTL, and single-use. Forgot-password / resend-verification responses don't leak whether an email is registered.Access-token revocation. Every access JWT carries a per-issuance
jtiandiat. The auth middleware checks two cache-backed blocklists after signature verification:- per-jti (
jwt:revoked:{jti}) — written byPOST /logout, kills the specific in-flight token - per-user (
jwt:user:{userId}:revoked-before) — written by password change and password reset, kills every previously issued token for that user without enumerating their JTIs
Cache lookup adds one round-trip per authenticated request. The trade-off vs. pure-stateless JWT is intentional: leaked access tokens are now revocable on the same cadence as refresh sessions, instead of surviving up to 15 minutes after logout. If the cache layer is unreachable the middleware fails open (token treated as not revoked) and emits
auth.jwt.revoke_check_failed— total auth outage is worse than the bounded leaked-token window the JWT TTL still caps. - per-jti (
-
Idempotency — Stripe webhooks dedup on
stripe_event_id(at-least-once delivery from an external service). User-facing mutations rely on natural idempotency instead of anIdempotency-Keyheader + dedup table:POST /notifications/mark-all-readfilters onstatus='unread', so a replay updates zero rows and skips the audit write.POST /auth/reset-passworddeletes the reset-token row inside the same transaction as the password update, so the token is single-use; a replay 400s with "Invalid or expired reset token," which is the correct outcome.
Adding an
Idempotency-Keyheader is only warranted for new endpoints that (a) charge money or (b) are invoked by external systems with retry semantics. -
Logging —
requestIdper request; sensitive query params (token,password,secret,key,auth,code,state, ...) redacted in every environment. -
DB — pool capped (20 prod / 10 dev), TLS verification on in prod by default, prepared statements enabled.
-
Container —
Dockerfile.prodis multi-stage, runs as UID 1001, wraps indumb-init, healthcheck on/health. Compose binds prod ports to127.0.0.1(front with a reverse proxy).
-
JWT_SECRETfreshly generated, ≥32 chars -
ALLOWED_ORIGINS— leave empty for same-origin (default); when set, real HTTPS hosts only, no wildcards -
POSTGRES_PASSWORDstrong + unique -
QUEUES_ENABLED=trueso transactional email retries on transient failure (the validator enforces this inNODE_ENV=production) -
TRUST_PROXY=truewhen behind a reverse proxy so per-IP rate limits actually key on the client IP, not the proxy's - DB backups scheduled
- Logs shipped somewhere queryable
- HTTPS terminated by reverse proxy / ingress
- Stripe webhook URL registered with matching
STRIPE_WEBHOOK_SECRET - Email sender domain has SPF / DKIM / DMARC
This is a template — vulnerability reports go to the project that instantiates it. Replace this section with your own contact when you spawn from this repo.