Security
This page describes the practical security model: what Nucleus does for you out of the box, what each default protects against, and what remains the operator's responsibility. Every claim below reflects the shipped behavior of the current release.
The short version: the defaults deny. Cross-origin requests, proxy headers, unauthenticated access to your routes, insecure session cookies, and unknown production config keys are all rejected until you explicitly allow them.
What you get by default
| Surface | Default behavior |
|---|---|
| Response headers | Hardened set on every response (see below). |
| CORS | Cross-origin requests denied until cors_origins lists origins. |
| Client IP | Proxy headers ignored until trusted_proxies is set. |
| Authorization | Default-deny RBAC: registered routes outside the bootstrap allow-list return 403 for anonymous callers; a path no route serves answers 404. |
| Session cookie | Secure, HttpOnly, SameSite=Lax. |
| Log output | Secret-bearing attributes redacted. |
| Passwords | bcrypt, cost 12. |
| JWT secret | Rejected at boot if shorter than 32 bytes. |
| Production config | Unknown keys are a boot error under NUCLEUS_ENV=production. |
Security headers
The default middleware stack sets these on every response:
X-Content-Type-Options: nosniffX-Frame-Options: DENYX-XSS-Protection: 0(the legacy filter is disabled deliberately; CSP is the real control)Referrer-Policy: strict-origin-when-cross-originContent-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:Permissions-Policy: camera=(), microphone=(), geolocation=()Cross-Origin-Opener-Policy: same-originCross-Origin-Resource-Policy: same-originStrict-Transport-Security: max-age=31536000; includeSubDomains— over a direct TLS connection, or on every response whenenv: production(so the header survives TLS termination at a proxy). Never on plain-HTTP development runs, so localhost is not pinned to HTTPS.
Handlers run after the middleware, so an app that needs a different CSP or
Permissions-Policy can override the header for its own routes.
How these defaults are kept honest
A security checklist written by hand drifts: it keeps claiming a protection long after the code stopped emitting it, and nobody notices until a pentest.
So the table above is not maintained by hand. A test boots a real
application, sends it a real request, and records what actually comes back —
every security header, the attributes of every cookie, and what a
cross-origin caller receives — for both a development and a production
profile. That recording is compared, byte for byte, against a checked-in
baseline (contracts/baseline/security_posture.txt).
The comparison is exact in both directions. A loosened default fails the build, which is the point. A tightened one fails too, because it changes the behavior of deployments that relied on the old posture — so it gets reviewed rather than slipped in. Either way the baseline has to be regenerated deliberately, with the reason stated in the change.
If you want to know what this release actually does, that file is the answer, and it cannot be out of date.
Secrets in logs are redacted
The structured logger redacts the values of secret-bearing attributes
before they reach any sink. A curated, case-insensitive denylist —
authorization, cookie, password, token, api_key, private_key,
DSN-style connection strings, and a few dozen more (observe.DefaultRedactedKeys
returns the full list) — is matched exactly against attribute keys, and
matching values are replaced with [REDACTED]:
logger.Info("login", "user", "ada", "password", supplied)
// {"msg":"login","user":"ada","password":"[REDACTED]"}
Exact matching is deliberate: suffix patterns like *_key would silently
swallow benign fields (cache_key, page_token) and hide debugging
information. Add app-specific fields via config instead:
log_redact_extra_keys:
- ssn
- card_number
There is intentionally no config key to disable redaction — a config
typo must never turn secret logging back on. Disabling it requires the
explicit code-level constructor (observe.NewLoggerWithRedaction with
redaction turned off). nucleus config print --effective applies the same
redaction to secret config values.
Sessions
Server-side sessions are managed with a pluggable store (memory, sql,
redis). The session cookie ships with:
Secure: trueby default — the cookie refuses to travel over plain HTTP. Local development overhttp://must opt out explicitly withsession_cookie_secure: false; production should never set that.HttpOnly: true— not readable from JavaScript.SameSite=Laxby default (session_cookie_samesite).- Absolute lifetime
session_lifetime(default 72h) plus an optional rollingsession_idle_timeout.
The cookie name supports the __Host- and __Secure- prefixes, and the
prefix preconditions are validated at startup: __Host- requires
Secure, path /, and no domain; __Secure- requires Secure. A
misconfigured prefix fails the boot instead of issuing a cookie every
browser would silently drop.
Operational note: the memory store is process-local — use sql or
redis with more than one replica. The sql store is dialect-aware for
SQLite, PostgreSQL, and MySQL.
CSRF protection
CSRF protection is opt-in via csrf_enabled: true, because it only
makes sense for cookie/session-authenticated browser routes — a pure
Bearer-token API does not need it (the mvc scaffold enables it; the api
scaffold does not). When enabled, verification is layered:
- Origin verification via the
Sec-Fetch-Siterequest header (same-origin/same-siterequests pass). - Double-submit token as the fallback for clients that do not send
Sec-Fetch-Site.
The CSRF token cookie is Secure by default and deliberately not
HttpOnly — client-side code must read it to echo it back. Exempt
Bearer-only subtrees or signature-authenticated webhook receivers with
csrf_exempt_paths (e.g. /api/).
CORS
Cross-origin requests are denied by default: with an empty
cors_origins (the default) no CORS headers are emitted at all. List exact
origins to allow them; the historical allow-all is the explicit opt-in
["*"]. cors_allow_credentials: true is only honored with a non-wildcard
origin list — the Fetch standard forbids credentials with *, and Nucleus
enforces that rather than emitting an invalid combination.
Authentication and authorization
- Passwords are hashed with bcrypt at cost 12.
- JWT, single-secret mode (
jwt_secret): HS256. A secret shorter than 32 bytes is a boot error, not a warning. Generate one withopenssl rand -base64 32. - JWT, keyset mode (
jwt_keys[]): multiple keys withkidheaders, HS256/RS256/ES256, and zero-downtime rotation — new tokens sign withjwt_current_kidwhile every listed key stays valid for verification. Key material is referenced (env var, PEM file, or AWS Secrets Manager), never inlined in tracked config files. - RBAC is default-deny. Anonymous callers reach only the bootstrap
allow-list (
/healthz,/livez,/readyz,/login,/.well-known/jwks.json,/static/*, and/metricsunlessmetrics_public: false); every other registered route answers 403 until a policy grants access. A path no route serves answers the plain 404 — the gate runs after routing, so a 403 always means "this route exists and is not granted". Policies live in a CSV file (rbac_policy_file) with explicitallow/denyrows and deny-override semantics.
Mass assignment and primary keys
Create respects a pre-assigned primary key: a non-zero key on the entity
travels in the INSERT. That is what makes client-generated UUIDs work —
and it also means a handler that decodes a request body straight into the
entity (BindJSON + Create) lets the HTTP client choose the row's key.
For models exposed to request payloads, do one of:
- decode into a DTO without a key field (or zero the key before
Create) — the pattern that also protects every other field you did not mean to accept, or - register the model with
RejectClientPK: true, which makesCreaterefuse entities that arrive carrying a key.
The default accepts pre-assigned keys. See Models & database for the exact semantics.
Rate limiting and client identity
Rate limiting is off by default; enable it for internet-facing deployments
(rate_limit_requests > 0, with rate_limit_window, rate_limit_burst,
and optional per-route / per-role partitioning). Its notion of "client" is
the client IP — which is why the proxy-header rule matters: forwarding
headers are ignored unless the immediate peer is in trusted_proxies.
Without that rule, anyone could evade limits or poison audit logs by
sending a forged X-Forwarded-For.
The limiter is one of the default subsystems. An application built
WithoutDefaults() — the api starter's shape — mounts it only with
WithRateLimit(), which the starter carries; without that option the
rate_limit_* keys enforce nothing, and the boot log says so in one ERROR
line (refused from v2.0.0, DEP-2026-016). Such an application decodes a
bearer token ahead of the limiter only with WithAuthz() (below): without
it the limiter keys a token's requests by their address, with it by the
token's user, as on the default stack. nucleus doctor --check security
and nucleus health --deploy read the composition root beside the
configuration and report that combination; where they cannot read it — a
deployed image carries the binary, not main.go — they say the limit holds
only on the default stack or with WithRateLimit(), rather than reporting
it in force.
Two things about trusted_proxies are worth knowing before you write it:
- An entry that is not an IP or a CIDR fails to load. It used to be discarded quietly, and with a single entry and a typo the list came out empty — the rule above then applied to nobody and forwarding headers were never read. It failed in the safe direction, which is precisely why it went unnoticed for so long. A typo now stops the boot and names the entry.
- Trusting everything is trusting everything, however you spell it.
nucleus doctor --check securityjudges the ranges together, so0.0.0.0/1plus128.0.0.0/1is flagged exactly like0.0.0.0/0. Under a catch-all the header that becomes attacker-controlled isX-Real-IP, which is honoured unconditionally once the peer is trusted — notX-Forwarded-For, which is walked right to left skipping trusted hops and therefore falls through when every hop is trusted.
Core-only applications
WithoutDefaults() leaves authorization out: no RBAC enforcer, no
default-deny middleware, no global bearer decode, and every route answers
anyone the handler lets through. The "defaults deny" summary at the top of
this page is about the default stack — and about a core-only application
that opts back in with WithAuthz().
WithAuthz(): the default stack's authorization, opt-in
nucleus.New().
FromConfigFile("nucleus.yml").
WithoutDefaults().
WithAuthz().
Start()
WithAuthz() (app.WithAuthz() for app.New) mounts the default stack's
authorization and nothing else of that stack, in the default stack's order:
- the bearer decode — a valid token's claims are in the context from here
on, so the API-key read, the rate limiter (
WithRateLimit()), the request interceptors and the handlers all see who is calling; - the API-key read (
WithAPIKeys()), the rate limiter and the interceptors, as without the option; - the default-deny gate, last: a request passes when one of its subjects —
the token's user and role, an API key's owner and its scopes as
scope:<name>, the signed-in account, thenanonymous— is allowed.
The enforcer loads rbac_policy_file (or a policy file found in the default
locations) and the bootstrap allow-list, leaving /metrics out under
metrics_public: false; the rows mounted modules declare in Policies load
into it. The profiler and the /realtime/{topic} channels sit behind the
gate like any route. WithOpenAuthz() switches the gate off here as on the
default stack. On the default stack WithAuthz() changes nothing.
With no policy file and no module rows, default-deny means every
registered route outside the bootstrap allow-list answers an anonymous
request 403 — /healthz, /livez, /readyz, /login,
/.well-known/jwks.json, /static/* and (unless metrics_public: false)
/metrics still answer; a path no route serves answers 404. That is the
default stack's behaviour too, and the boot log says it in one line:
level=WARN msg="authz: default-deny with 0 policy rows — an anonymous request reaches only the bootstrap routes, and every other registered route answers 403 until a row allows it: …" bootstrap_routes="/healthz, /livez, /readyz, /metrics, /login, /.well-known/jwks.json, /static/*"
With a policy file the line is an INFO naming the file and how many rows
it carries (authz: default-deny with 12 policy rows from rbac_policy.csv);
each module whose rows load says so in a line of its own.
The api starter does not carry WithAuthz(): its documented contract is
"no authz", and nucleus new --template api names the option as the way to
add it. nucleus serve --without-defaults does not mount it either — the
plain nucleus serve is the configuration-only server with an enforcer.
Without WithAuthz()
What the configuration or the application asks to have guarded is not, and
the boot log says so, one ERROR line each; from v2.0.0 each combination
refuses to start unless WithAuthz() guards it:
rbac_policy_fileloads nothing andmetrics_public: falsegates nothing —authz configuration IGNORED(DEP-2026-017).nucleus doctor --check rbacsays the same when it finds the composition root.- The rows modules declare in
Policiesare discarded, deny rows included: there is no enforcer to load them into. A module that lets anonymous callers read and keeps writes for a role — whatnucleus generate modulewrites — answers every write to anyone here. The boot log line,module policies DISCARDED, names the modules, how many rows and deny rows they declare, and the routes their rows would have refused an anonymous caller (DEP-2026-017). A module whose rows grant anonymous callers every action on every route it serves — the accounts module — loses nothing and is not reported. - The profiler answers anyone.
profiling_enabled: truemounts/debug/pprof, and heap and goroutine dumps — live process memory — answer anyone who reaches the port, unless the application's own middleware refuses them. The boot log line,profiler UNGUARDED, names heap dumps of the production process outright in production;nucleus doctor --check securityreports it (an error in production, a warning elsewhere) when it finds the composition root (DEP-2026-018). Leaveprofiling_enabledoff on such an application, and when you need a profile servenet/http/pproffrom a listener only operators reach.
Two more carry no ERROR line, because nothing in the configuration asks for
them: an API key's scopes authorize nothing — apikeys.Require on a route
is the only check — and every realtime topic is open to whoever reaches the
route, which the INFO line that announces the channels says.
The metrics endpoint
/metrics (configurable via metrics_path) carries no authentication of
its own and is on the anonymous allow-list by default, matching the
common "scraper on a private network" setup. If your network layer does not
isolate it, either set metrics_public: false (putting it behind the RBAC
enforcer, so your scraper needs a policy and credentials) or firewall the
path at the proxy. Metric values are operational data — treat them
accordingly. On an application built WithoutDefaults() there is an
enforcer to put it behind only with WithAuthz(); without it, firewalling
is the only option.
Secrets
- Supply secrets through the environment (
NUCLEUS_JWT_SECRET,NUCLEUS_DATABASES__DEFAULT__URL,NUCLEUS_SMTP_PASS, …) or throughjwt_keys[]references — never in files you commit. jwt_secretis non-nullable: setting it tonullin a file, or exporting an emptyNUCLEUS_JWT_SECRET=, is a boot error rather than a silent fall-back to no secret.NUCLEUS_ENV=productionforces strict config validation: unknown keys in config files fail the boot even if development code downgraded them to warnings.
File permissions (operator's job)
Nucleus reads these files but does not manage their permissions — keep them tight on the host:
- config files that contain connection strings: owner-only (
0600), - PEM private keys referenced by
jwt_keys[].pem_path:0600, - the systemd
EnvironmentFileholding secrets:0600, owned by root, - the RBAC policy CSV: writable only by the deploy user (it is an authorization database).
Hardening checklist
-
env: productionandNUCLEUS_ENV=productionset. - TLS everywhere: terminate at the proxy (with
env: productionfor HSTS) or serve directly withtls_cert_file/tls_key_file. -
jwt_keys[]with rotation (or a ≥32-bytejwt_secret), material referenced from env / files / secret manager. -
session_cookie_secure: true(default) untouched; consider a__Host-cookie name over HTTPS. -
csrf_enabled: truefor any browser-facing, session-authenticated app. -
cors_originslists exact origins — no["*"]unless the API is deliberately public. -
trusted_proxiesset to the load balancer ranges, nothing wider. -
rate_limit_requests> 0 for internet-facing deployments — and, on an application builtWithoutDefaults(),WithRateLimit()in the composition root (norate_limit_requests IGNOREDline at boot). - On an application built
WithoutDefaults():WithAuthz()in the composition root if its routes are meant to be authorized — or elseprofiling_enabledoff (noprofiler UNGUARDEDline at boot), and nomodule policies DISCARDEDline, or the routes it names guarded by the application itself. -
/metricsnetwork-restricted ormetrics_public: false. - RBAC policy reviewed: default-deny left intact, explicit
denyrows for sensitive paths. - Models bound to request payloads use DTOs or
RejectClientPK— the client must not pick primary keys. - Secret files at
0600; secrets absent from tracked config. -
nucleus doctor --check securityclean — it looks for settings that load fine and expose you anyway: a wildcard CORS allow-list, a catch-alltrusted_proxiesrange, a guessablejwt_secret, a profiler on an application that builds no enforcer to guard it (one builtWithoutDefaults()withoutWithAuthz()). -
nucleus health --deploygreen in the release pipeline. - The release archive you deployed verified against its signature and its build provenance — see Verifying a release, which also says how to tell whether the release you have is one that carries them.
Report suspected vulnerabilities through the repository's security policy on GitHub rather than a public issue.