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.
RequirePermissionfails 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:
- The custom header
x-dgov-csrf— a cross-site form cannot set it. - 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.ValidatePayloadsthrough 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¶
.envandbackend.envare gitignored; values are generated on the host withopenssl 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.