# Security Hardening and Defaults

> The security defaults built into nauthera-server: startup guards, credential hashing, FIPS 140-3 mode, HTTP hardening, the mTLS control plane and a checklist.

## Startup guards

The server checks its own configuration and refuses to start on settings that would
fail quietly in production. For **every** issuer, it requires:

| Requirement | Why |
|---|---|
| `https` issuer with no path | Discovery, cookies and the DPoP `htu` all depend on one fixed origin, and the session cookie is `Secure` only on an https issuer. |
| `mfa.provider: file` when `mfa.required: true` | Otherwise TOTP seeds become unreadable after a restart, and with mandatory MFA every user depends on them. |

Whenever the issuer host is not loopback, it also requires:

| Requirement | Why |
|---|---|
| `cache.enabled: true` | Otherwise authorization codes live in one replica's memory, and a code issued by one replica cannot be redeemed at another. |
| `keys.provider: file` | Otherwise signing keys change on every restart. |
| `mfa.provider: file` | Otherwise TOTP seeds become unreadable after a restart. |

On any issuer it also refuses a `security.trusted_proxies` range that covers every
address, a forwarded `security.client_ip.mode` without `trusted_proxies`, a
`server.grpc.allow_insecure` listener on an address that is not loopback, and a Redis
whose `maxmemory-policy` may evict keys.

The Helm chart runs the same checks when it renders, and `values.schema.json` rejects
unknown keys. See [Installation](https://www.nauthera.io/docs/v1/installation#what-the-chart-checks-for-you).

Not everything is checked. An unset `keys.encryption_key` only logs a warning, and an
empty mounted secret file is not refused: it sets its key to an empty value. See
[Errors & Troubleshooting](https://www.nauthera.io/docs/v1/troubleshooting#the-server-refuses-to-start) for
what fails quietly.

## Credentials

- **Hashing.** Passwords and MFA recovery codes are hashed with **argon2id**, or with
  PBKDF2-HMAC-SHA-256 under the FIPS policy (see [FIPS 140-3](#fips-140-3)). Secrets the
  server generates itself, client secrets and registration access tokens, are 256 random
  bits, so they are stored as a SHA-256 digest: a slow derivation adds nothing against a
  value nobody can enumerate. An older argon2id hash of a client secret still verifies,
  and is replaced by the digest the first time it does. A password or recovery code is
  never checked against such a digest: a stored SHA-256 value for one is refused as
  malformed, even when it matches. Email links for password reset, address verification
  and invitations, and refresh tokens, are stored only as a digest too. An identity
  provider's client secret is sealed with AES-256-GCM under `keys.encryption_key` and
  never returned. A database dump is not a dump of credentials, and comparisons run in
  constant time.
- **Derivation budget.** Every password-grade derivation in the process, sign-in,
  setting a password, and redeeming or issuing recovery codes, draws from one budget of
  `auth.brute_force.max_concurrent_verifies` (4) slots, each argon2id derivation using
  about 64 MiB. A request that gets no slot within `verify_acquire_timeout` (2s) is
  refused with `429` instead of queueing, so memory stays bounded under load.
- **Randomness.** Every identifier, token and secret comes from `crypto/rand`.
- **Secrets returned once.** Generated client secrets are returned once and never logged.
  The admin API returns a new organization's `{slug}-admin-m2m` secret once, as
  `admin_m2m_secret`. For the main organization, that secret is the bootstrap admin
  secret: it is written once to `auth.admin.bootstrap_secret_path`, and discarded when
  that path is empty, which is the default. The gRPC `CreateOrganization` call returns no
  secret, so the `{slug}-admin-m2m` client of an organization created that way cannot be
  used.
- **Brute force.** Per-IP and per-identifier throttles, plus a durable per-account soft
  lock with backoff: each further failure doubles the next lock, up to `lockout_max`. An
  identifier no account holds locks on the same attempt, so the answers do not reveal
  whether an account exists. TOTP codes and recovery codes each have their own lock.
- **Captcha.** Registration and every request for an emailed link ask for a captcha, and
  sign-in does too once the address typed or the caller's IP failed
  `auth.captcha.sign_in_after` (3) times recently. The default provider is the built-in
  ALTCHA proof-of-work, which needs no third party. See
  [Rate Limits & Lockout](https://www.nauthera.io/docs/v1/rate-limits#captcha).
- **Account status.** An account an administrator suspended or blocked cannot sign in,
  and cannot obtain or renew a token through any grant. See
  [Rate Limits & Lockout](https://www.nauthera.io/docs/v1/rate-limits) and
  [Multi-Factor Authentication](https://www.nauthera.io/docs/v1/mfa#lockout-and-the-acceptance-window).
- **Administrators.** No administrator can change the status, credentials, email address
  or username of their own account or of one more privileged than theirs, or delete it,
  through the admin API; such a call answers `403 protected_account`. See
  [Admin API & Users](https://www.nauthera.io/docs/v1/user-management#protected-accounts).

## The client address

Every per-IP throttle, the rate limiter and the audit log key on the client address, so
behind a reverse proxy the server must know which peer to believe. `security.client_ip.mode`
picks the source: `socket` (the connecting peer), `x-forwarded-for` (walked right to left
through `security.trusted_proxies`), or `header` (one of `X-Real-IP`, `True-Client-IP` or
`CF-Connecting-IP`, from a trusted peer). Unset, it is `x-forwarded-for` when
`trusted_proxies` is set and `socket` otherwise.

List your proxies' own addresses: every workload inside a trusted range can name its own
client address. A range wider than `security.trusted_proxies_warn_prefix` (`/16` IPv4,
`/48` IPv6) is logged at startup. When traffic looks like it arrives through a proxy the
configuration does not name, or a trusted range is wider than it needs to be, the
dashboard's overview tells platform administrators which proxies it saw and what to set;
the same finding is served at `GET /v1/admin/deployment/client-ip`. A trusted proxy is
also trusted to set `X-Request-ID` and `traceparent`. See
[Configuration](https://www.nauthera.io/docs/v1/configuration#the-client-address).

## FIPS 140-3

Set `fips.required: true` and the server refuses to start unless the Go FIPS 140-3
module is on and pinned to a frozen version. Build with `GOFIPS140=v1.0.0`: such a binary
runs in FIPS mode by default, with no `GODEBUG` setting. An ordinary build run with
`GODEBUG=fips140=on` is refused, because its module is not pinned, and so is a pinned
build run with `GODEBUG=fips140=off`. The repository's Dockerfile does not build that
way, so FIPS deployments need their own build.

An organization under the FIPS policy derives new passwords and recovery codes with
PBKDF2-HMAC-SHA-256 (600,000 iterations) instead of argon2id. Hashes written earlier keep
verifying as argon2id and are not rehashed. `fips.required: true` puts the main
organization under the policy, and every other organization follows only its own
`fips_mode`, which the admin API's `POST /v1/admin/orgs`, the gRPC `CreateOrganization`
call and an `Organization` manifest's `fipsMode` set at creation. A process that fails
the check above refuses to create or serve such an organization. See
[Organizations](https://www.nauthera.io/docs/v1/organizations#fapi-mode-and-fips-mode).

## HTTP

- Hardened server timeouts on every listener (`read_header_timeout`, `read_timeout`,
  `write_timeout`, `idle_timeout`, `max_header_bytes`) and a request-body cap
  (`max_body_bytes`, 256 KiB).
- Security headers on every response: `X-Content-Type-Options: nosniff`,
  `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY` and a Content-Security-Policy.
  HTML pages set fixed policies of their own that `security.content_security_policy`
  does not change. For example, the sign-in pages use `default-src 'self'` with
  `frame-ancestors 'none'`, and the session management iframe at `/check_session_iframe`
  sends `frame-ancestors *`, so any page can frame it, whether or not session management
  is enabled. Every response that is not an HTML page carries
  `security.content_security_policy` (default `default-src 'none'; frame-ancestors 'none'`).
  HSTS is sent when `security.hsts` is on, and only on responses that reach the client
  encrypted, either directly or through a trusted proxy's `X-Forwarded-Proto`.
- `Cache-Control: no-store` on OAuth responses.
- CORS is off unless you configure it (`security.cors.*`).
- `__Host-` prefixed session cookies.
- Exact `redirect_uri` matching. No wildcards, no prefixes.
- URLs the server fetches, a client's `jwks_uri` and an identity provider's discovery
  document, token endpoint and keys, must be https and must not resolve to loopback,
  private, link-local or shared address space. The address is checked again at dial
  time, and redirects are not followed. Through an `HTTPS_PROXY`, that dial-time check
  runs on the dial to the proxy, so a proxy on a private address is refused too. A proxy
  on a public address resolves the names itself, so its own egress policy has to keep
  these fetches off internal addresses.
- Back-channel logout tokens are sent only to a `backchannel_logout_uri` held to the same
  rule, with the address checked again at dial time. Through a proxy, the server
  resolves the relying party's name itself and asks the proxy to connect only to an
  address it checked, so a relying party on a private address is refused with or without
  a proxy. The proxy's own address is not checked, so it can be on the private network.
- Errors carry no internal detail. The OAuth endpoints answer with the RFC 6749 error
  object, `/register` with the RFC 7591 one, and every other endpoint, `/userinfo`
  included, with an RFC 9457 problem document. A request refused before its handler
  runs, such as by the rate limit, gets a problem document on every endpoint, though a
  browser navigation can get an HTML error page instead. Every response carries an
  `X-Request-ID` header. DSNs, driver errors, stack traces and secrets are logged on the
  server and never returned. See
  [Errors & Troubleshooting](https://www.nauthera.io/docs/v1/troubleshooting#error-formats).

## The control plane

The gRPC `AdminService` can create clients and generate their secrets, so:

- it uses TLS 1.3 with `RequireAndVerifyClientCert`, and admits only the identities in
  `server.grpc.tls.allowed_clients`, each matched by a typed certificate SAN
  (`uri:<uri>` or `dns:<name>`, never the Common Name), granted only the operations
  it lists (`organizations`, `clients`, `brandings`, `identity_providers`,
  `ldap_directories`, `broker_memberships`, `email_domains`), and bounded
  to the `organizations` (by slug) and `namespaces` it lists, where `*` covers every
  one. An entry that leaves either empty where one of its operations needs it refuses
  startup,
- plaintext needs an explicit `server.grpc.allow_insecure: true`, and only on a loopback
  address,
- with `audit.fail_closed` on, an organization change is refused while the audit log
  cannot record it, and
- the Helm chart never publishes it on a NodePort or load balancer.

The server reads its gRPC certificate, key and client CA again at each handshake, so a
renewal takes effect without a restart. A replacement it cannot use is logged once, and
the last good material stays in use.

## Checklist

- [ ] The admin listener (`:9090`) and the gRPC listener (`:9091`) are reachable only
      from inside the cluster.
- [ ] `server.admin.pprof` is off.
- [ ] The gRPC API runs with mTLS and an identity allowlist that grants each identity
      only the operations, organizations and namespaces it needs, and `allow_insecure`
      is unset.
- [ ] Secrets are delivered as files (`_FILE`), not as environment variables.
- [ ] `security.hsts: true` behind TLS, and `trusted_proxies` lists only your proxies'
      own addresses.
- [ ] Redis runs with `noeviction` and persistence, with a replica per primary through
      `cache.mode: sentinel` or `cluster`.
- [ ] The bootstrap admin secret file has been read, stored and removed.
- [ ] `auth.registration.enabled` is on only if you want self-registration.
- [ ] Audit retention and `fail_closed` match your compliance needs.
- [ ] On PostgreSQL, the server runs as an append-only role (`appendOnlyAudit` in the
      chart), and `audit.checkpoint_key_path` is set. See
      [Audit Log](https://www.nauthera.io/docs/v1/audit-log#the-append-only-role).

## Reporting a vulnerability

Report vulnerabilities privately, following the disclosure process in the repository's
`docs/SECURITY.md`, and not as public issues. The [security page](https://www.nauthera.io/security) has the
address to write to, what is in scope and when to expect an answer.
