Skip to content

Security

The controls that are implemented, where they live, and why they were decided that way.

The layers

graph TD
  A["Edge nginx — TLS · HSTS · rate limits"] --> B["Next.js BFF — httpOnly cookies · CSRF"]
  B --> C["Go API — JWT · RBAC · rate limits · timeouts"]
  C --> D["PostgreSQL — Row-Level Security"]
  C --> E["Audit log — hash chain"]

If one layer fails the next one holds. An XSS does not leak tokens because they sit in httpOnly cookies; a forgotten SQL predicate does not leak rows because RLS still applies.

Authentication and session

Control Implementation
No passwords Identity only from eID / Google / SSO
Token rotation The pair is rotated on every refresh
kind claim guard A refresh token cannot be used as an access token
Revocation cutoff Tokens issued before TokensRevokedBefore are rejected
Logout Revokes refresh, deny-lists access (Redis)
Lockout Failed attempts recorded in login_events, then locked
Super-admin MFA TOTP mandatory; the secret is AES-256-GCM encrypted

Authorization

  • Two layers: JWT role/permission at HTTP, RLS in the database.
  • RequirePermission fails closed if the resolver errors.
  • The super-admin surface is gated by its own middleware.
  • Details: RBAC & super admin.

Security headers

The backend sets these on every response:

X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(),
                    magnetometer=(), microphone=(), payment=(), usb=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-site
Cross-Origin-Embedder-Policy: require-corp
Strict-Transport-Security: max-age=31536000; includeSubDomains   ← production HTTPS only

Why default-src 'none' on an API?

The API only returns JSON — it needs to load nothing. The strictest possible CSP costs nothing here.

Frontend headers are defined in next.config.mjs and asserted by securityHeaders.test.ts. The microphone is needed for AI voice chat and translation, so the frontend's Permissions-Policy opens it to self — the backend's stays closed.

CSRF

Double protection at the BFF layer:

  1. The custom header x-dgov-csrf — a cross-site form cannot set it.
  2. Origin / Referer validation (checkOrigin).

Every mutating browser call goes through sendJSON / postJSON in lib/client.ts.

Rate limits and timeouts

Surface Limit
/v1/auth/* ~5/min, 4 KiB body cap
/v1/ai/* ~20/min (burst 5), 50s timeout
/v1/public/ai/* ~6/min (burst 3), messages ≤1000 characters
eID poll ~60/min (burst 30)
gov / assets / gspace writes ~30/min (burst 15)
Everything else 30s server timeout

The edge nginx adds a second layer: 5r/s on the sign-in paths, 50r/s elsewhere.

SQL and input

  • No ORM — every query is hand-written and parameterised. Queries are never assembled by string concatenation.
  • Every DTO is validated by validators.ValidatePayloads through struct tags.
  • Body size limits apply globally and per route.

SSRF protection

The remote-asset fetch client refuses internal addresses (isDisallowedFetchIP): loopback, private and link-local ranges are blocked. The OIDC provider deliberately does not support request / request_uri (JAR), which would widen the SSRF surface.

Encryption

What How
Integration OAuth tokens AES-256-GCM (INTEGRATION_ENC_KEY)
Super-admin TOTP secrets AES-256-GCM (same key)
Recovery codes Hashed
Client secrets Hashed (pkg/secrethash) — no reversible storage
Admin API keys Hashed; shown only at issue time
Webhook secrets 64 hex characters per platform

Audit

audit_log is a hash-chained, append-only log. Each row carries the hash of the previous one.

Endpoint What
GET /v1/audit/ Read the log (admin)
GET /v1/audit/verify Verify the chain's integrity

Altering a row in the middle breaks the chain. See Observability & audit.

Boot guards

In production a violation means the API does not start:

Guard Check
RLS enforceability The DB role is not superuser / BYPASSRLS
TLS The DSN uses sslmode=verify-full
Key lengths JWT_SECRET ≥32, SSO_STATE_KEY ≥32, INTEGRATION_ENC_KEY ≥16
Document-Signer Certificate and key present
CORS * forbidden

Why refusing to start is the right behaviour

Quietly degrading is the dangerous option — the operator keeps believing everything is fine. A hard stop surfaces the problem at deploy time.

Secret management

  • .env and backend.env are gitignored; values are generated on the host with openssl rand.
  • CI runs the gitleaks secret scanner.
  • The CI deploy SSH key is separate from any operator's personal key.

Known items

Item Status
TSA (timestamping) for signatures A later stage — currently PAdES-T
Pairwise subject_type Not implemented (all clients are public)
golangci-lint Temporarily removed until it supports Go 1.26; go vet + gofmt run instead

Vulnerability reporting: see SECURITY.md in the repository.