Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

Security Hardening and Defaults

Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated

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:

RequirementWhy
https issuer with no pathDiscovery, 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: trueOtherwise 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:

RequirementWhy
cache.enabled: trueOtherwise authorization codes live in one replica's memory, and a code issued by one replica cannot be redeemed at another.
keys.provider: fileOtherwise signing keys change on every restart.
mfa.provider: fileOtherwise 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.

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 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). 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.
  • 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 and Multi-Factor Authentication.
  • 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.

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.

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.

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.

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.

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 has the address to write to, what is in scope and when to expect an answer.