# Configuring nauthera-server

> How nauthera-server is configured: the YAML file, NAUTHERA_SERVER_ environment variables, secret files, every top-level section and the main organization.

The server loads its configuration in this order of precedence:
**defaults < YAML file < environment < secret files**. Point it at a file with
`--config` (or `NAUTHERA_SERVER_CONFIG`). Environment variables win over the YAML file,
and a secret file named by a `_FILE` variable wins over both. See
[Secrets from files](#secrets-from-files).

```bash
nauthera-server --config /etc/nauthera/config.yaml
```

The annotated reference is `config.example.yaml` in the nauthera-server repository.

## Environment variables

Every key has the prefix `NAUTHERA_SERVER_`. To get the variable name, upper-case the
dotted key and replace `.` and `-` with `_`:

```text
server.http.read_header_timeout  ->  NAUTHERA_SERVER_SERVER_HTTP_READ_HEADER_TIMEOUT
database.dsn                     ->  NAUTHERA_SERVER_DATABASE_DSN
tracing.sample_ratio             ->  NAUTHERA_SERVER_TRACING_SAMPLE_RATIO
```

Durations are Go duration strings (`"15s"`, `"30m"`). Lists are comma-separated.

> **Prefer the file.** The file is decoded strictly, so a mistyped key is a startup error
> that names it. A mistyped environment variable is silently ignored, and you run on a
> default you believe you overrode. The Helm chart renders `config` to a file for this
> reason.

## Secrets from files

Keys that hold secrets also accept a mounted file. Append `_FILE` to the variable name:

```bash
NAUTHERA_SERVER_DATABASE_DSN_FILE=/var/run/secrets/db/uri
NAUTHERA_SERVER_DATABASE_PASSWORD_FILE=/var/run/secrets/db/password
NAUTHERA_SERVER_CACHE_PASSWORD_FILE=/var/run/secrets/redis/password
NAUTHERA_SERVER_CACHE_SENTINEL_PASSWORD_FILE=/var/run/secrets/redis/sentinel-password
NAUTHERA_SERVER_MFA_ENCRYPTION_KEY_FILE=/var/run/secrets/mfa/key
NAUTHERA_SERVER_KEYS_ENCRYPTION_KEY_FILE=/var/run/secrets/keys/kek
NAUTHERA_SERVER_MAIL_SMTP_PASSWORD_FILE=/var/run/secrets/smtp/password
NAUTHERA_SERVER_AUTH_CAPTCHA_SECRET_FILE=/var/run/secrets/captcha/secret
```

A file's contents, with surrounding whitespace trimmed, outrank both the YAML file and the
plain environment variable. An empty file therefore blanks a value set in either place.
A file that cannot be read stops the server at startup.

The retired-key lists (`mfa.retired_keys`, `keys.retired_keys`) have no `_FILE` form, so
pass them through the environment. `audit.checkpoint_key_path` is a path, not a
secret value, so it has no `_FILE` form either.

## Top-level sections

| Section | What it configures |
|---|---|
| `server` | The three listeners: `server.http` (`:8080`, timeouts, body cap, TLS), `server.admin` (`:9090`, `pprof`), `server.grpc` (`:9091`, mTLS with `tls.client_ca_file` and `tls.allowed_clients`, both certificate and CA read at each handshake, `allow_insecure` and `reflection`), plus `shutdown_timeout` and `drain_delay`. |
| `logging` | `level` (`debug`, `info`, `warn` or `error`), `format` (`json` or `console`), `development`. |
| `database` | PostgreSQL, the only database: `driver` (`postgres`, the default and only accepted value), a `dsn` or discrete `host`, `port`, `user`, `password`, `name`, `sslmode` (`verify-full` by default, which checks the database's certificate and name; any libpq mode is accepted) and `sslrootcert` (the CA file a database with a private CA needs), `pgbouncer`, pool sizing, the session bounds `statement_timeout` (30s), `lock_timeout` (10s) and `idle_in_transaction_timeout` (1m), `migrate`, and `runtime_role`, the role the server serves requests as when it does not own the schema. See [Backups & Upgrades](https://www.nauthera.io/docs/v1/operations#schema-migrations). |
| `cache` | A Redis-compatible cache: `enabled`, `mode` (`standalone` at `address`, or `sentinel` and `cluster` at `addresses`, with `master_name` for Sentinel), `username`, `password`, `sentinel_password`, `replica_acks` and `replica_ack_timeout` (replicas that must confirm each write), `db`, `tls` and `tls_ca_file` (the CA the Redis certificate is verified against, read only with `tls`), timeouts and pool size. Required for any issuer that is not loopback. The server refuses to start against a Redis whose `maxmemory-policy` may evict keys. |
| `metrics` | `enabled`, `path` (default `/metrics` on the admin listener). |
| `rate_limit` | Per-IP limits: `enabled`, `rps`, `burst`, `backend` (`memory`, a token bucket per replica, or `redis`, a fixed window shared by every replica) and `window`. While Redis cannot answer, the `redis` backend limits each replica on its own `rps` and `burst` bucket, and `nauthera_rate_limit_degraded` reads 1. See [Rate Limits & Lockout](https://www.nauthera.io/docs/v1/rate-limits). |
| `security` | `trusted_proxies`, `trusted_proxies_warn_prefix`, `client_ip.mode` and `client_ip.header`, `network_zones`, `hsts`, `hsts_max_age`, `frame_deny`, `content_security_policy`, `sign_in_opener_policy` (`same-origin` by default; `unsafe-none` for a relying party that signs in through a popup and reads `window.opener`), `cors.*`. See [The client address](#the-client-address) and [Network zones](#network-zones). |
| `issuer` | `url` and the token lifetimes: `access_token_ttl` (10m), `id_token_ttl` (10m), `refresh_token_ttl` (720h), `auth_code_ttl` (60s). |
| `keys` | Signing keys: `provider` (`memory` for development, or `file`), `signing_key_path`, `rsa_signing_key_path`, `verification_key_paths` and `rsa_verification_key_paths` for extra public keys to publish during a rotation, `default_alg`, plus the per-organization key-encryption key `encryption_key` and its `retired_keys`. See [Keys & Secrets](https://www.nauthera.io/docs/v1/keys-and-secrets). |
| `tracing` | OpenTelemetry over OTLP/gRPC: `enabled`, `endpoint`, `insecure`, `sample_ratio`, `environment`. |
| `auth` | `brute_force.*` (lockout, per-IP and per-identifier throttles, and `max_concurrent_verifies`, the process-wide limit on password hashing), `session.*` (cookie, idle and absolute TTL, `history_retention`), `pending.*`, `session_management.enabled`, `registration.enabled`, `password.*` (a new password's `min_length`, 8, and `max_length`, 128, at most 1024; `refuse_common`; and `breach_lookup.*`, an opt-in Pwned Passwords range check that sends only the first five characters of the password's SHA-1: `enabled`, `url`, `timeout`, `fail_open`), `captcha.*` ([below](#captcha)), `password_reset.*`, `email_verification.*` and `invitation.*` (link `validity` and `max_requests`), and `admin.*` (`subjects`, `bootstrap_secret_path`, `max_auth_age`). See [Sessions, Consent & Logout](https://www.nauthera.io/docs/v1/sessions). |
| `mfa` | The deployment's second-factor policy (`required`, `methods`, `passwordless`, `remember_browser_for`, `spare_zones`, `rules`; [below](#the-second-factor-policy)), the one-time-password seed cipher (`provider`, `memory` or `file`, `encryption_key`, `retired_keys`), `issuer` (the label shown in authenticator apps), `drift`, `max_failures`, `lock_duration`, `recovery_code_count`, and `passkey.*` ([below](#passkeys)). See [Multi-Factor Authentication](https://www.nauthera.io/docs/v1/mfa). |
| `orgs` | `default_slug`, the main organization's slug (`default`), and `main.*`, settings applied to the main organization at every startup ([below](#the-main-organization)). |
| `audit` | `enabled`, `level`, `include` and `exclude` globs, `fail_closed`, buffering, refusal coalescing, `retention` (9600h), `partition_ahead_months`, `partition_maintenance` (`internal`, or `external` when `nauthera-server maintain` runs as its own job), signed chain checkpoints (`checkpoint_interval`, `checkpoint_key_path`, and `checkpoint_key_required`, which refuses to start without a key), and `export.*` for forwarding the stored log. See [Audit Log](https://www.nauthera.io/docs/v1/audit-log). |
| `fapi` | `require_pushed_authorization_requests`, which requires PAR deployment-wide. |
| `fips` | `required`, which puts the main organization under the FIPS policy and refuses to start unless the Go FIPS 140-3 module is on and pinned to a frozen version. A binary built with `GOFIPS140=v1.0.0` passes with no `GODEBUG` setting; an ordinary build run with `GODEBUG=fips140=on` is refused. See [Hardening](https://www.nauthera.io/docs/v1/security#fips-140-3). |
| `mail` | The deployment's outbound relay: `smtp.host`, `smtp.port` (587), `smtp.tls` (`starttls`, `implicit`, or `none` for a relay without TLS), `smtp.allow_plaintext_auth`, `smtp.username`, `smtp.password`, `from.address`, `from.name`, `reply_to`, `local_name`, `timeout`; and `organizations`, per-organization relays by slug, whose credentials are read from `username_file` and `password_file` at every delivery. |
| `geoip` | `database`, an optional GeoLite2-City or GeoIP2-City file that lets a new-sign-in alert name where the sign-in came from. |

## The client address

Per-IP throttles, the rate limiter and the audit log key on the client address.
`security.client_ip.mode` says where it comes from:

| Mode | Address |
|---|---|
| `socket` | The connecting peer. |
| `x-forwarded-for` | `X-Forwarded-For` from a peer in `trusted_proxies`, walked right to left. |
| `header` | The one header `client_ip.header` names (`X-Real-IP`, `True-Client-IP` or `CF-Connecting-IP`), from a peer in `trusted_proxies`. |

Unset, the mode is `x-forwarded-for` when `trusted_proxies` is set and `socket`
otherwise; any mode but `socket` needs `trusted_proxies`. Every workload inside a trusted
range can name its own client address, so list your proxies' own addresses. A range
covering every address (`0.0.0.0/0`, `::/0`) is refused at startup, and one wider than
`trusted_proxies_warn_prefix` (`/16` for IPv4, `/48` for IPv6) is reported with a warning.
Behind a proxy, `socket` makes every client share one bucket; the dashboard's overview
then tells platform administrators which proxies it saw and what to set.

## Network zones

`security.network_zones` names networks a sign-in policy can refer to: a lowercase name
(letters, digits, hyphens) mapped to IPv4 and IPv6 ranges in CIDR notation. It is a map,
so it can be set only in the file:

```yaml
security:
  network_zones:
    office: ["203.0.113.0/24", "2001:db8:1::/48"]
    vpn: ["198.51.100.0/24"]
```

A sign-in is in a zone when its client address, as `client_ip` reads it, falls in one of
the ranges. Every sign-in's audit record, completed or refused, names its zones
(`network_zones`). An organization declares its own zones beside these and may not reuse
a name. A range that covers every address of its family, or a name that is not a
lowercase label, refuses startup.

## The second-factor policy

The first six `mfa` keys are the deployment's second-factor policy. It is a floor: each
organization, and then each client, may tighten it and never loosen it. An organization
or client sets its own through the admin API, the control plane, or its
[Organization](https://www.nauthera.io/docs/v1/organization-resource) or [OIDCClient](https://www.nauthera.io/docs/v1/oidc-client)
resource.

| Key | Default | Meaning |
|---|---|---|
| `mfa.required` | `false` | A user with no permitted factor is sent through enrolment before a session is issued, at sign-in and at self-service registration. |
| `mfa.methods` | `[totp, passkey]` | The second-factor methods offered at all: `totp`, `passkey` and `email` (an emailed code). Empty means `totp` and `passkey`; `email` is offered only when listed. |
| `mfa.passwordless` | `false` | Opens sign-in with a passkey alone, for every organization and client that also opens it. Refused at startup when `mfa.methods` lists methods and `passkey` is not one. |
| `mfa.remember_browser_for` | `0s` | A user who holds a second factor is not asked for it again on a browser that passed one for them within this window, while their password and factors are unchanged. `0` remembers no browser; at most `4320h`. Organizations and clients may shorten it or always challenge. |
| `mfa.spare_zones` | `[]` | The `security.network_zones` in which a user who holds a second factor is not asked for it. Only these deployment zones can relax the challenge. An undeclared name refuses startup. |
| `mfa.rules` | `[]` | Rules that narrow the policy for the sign-ins that meet them (file only). |

A sign-in spared its second factor, by a remembered browser or a zone, is single-factor:
its session and ID token carry the first factor's `amr` and no `mfa`, and its audit record
says `mfa_spared: remembered_browser` or `mfa_spared: network_zone`. An identity provider
with `spareSecondFactor` spares the sign-ins through it outside this policy
(`mfa_spared: identity_provider`; see
[IdentityProvider](https://www.nauthera.io/docs/v1/identity-provider-resource#trust-settings)). An organization's
admin console always asks for the second factor, because its step-up needs a sign-in
that used it.

Each rule has the fields `name`, `outside_zones`, `inside_zones`, `new_network`,
`new_browser`, `methods` and `require`. It holds when every condition it sets does: the client address is in none of
`outside_zones` or in one of `inside_zones`, the network is one the user has never
completed a sign-in from, or the browser has not passed a second factor for the user. It
then narrows the methods to `methods`, requires a second factor (`require`, which no
exemption spares), or both. A rule cannot loosen anything.

```yaml
mfa:
  spare_zones: [office]
  rules:
    - name: passkey-off-site
      outside_zones: [office, vpn]
      methods: [passkey]
      require: true
```

The audit record of a completed or interrupted sign-in names the rules that held
(`mfa_rules`). A rule with no name, a repeated name, no condition, no effect, an unknown
method or an undeclared zone refuses startup.

## Passkeys

| Key | Default | Meaning |
|---|---|---|
| `mfa.passkey.rp_id` | `""` | The relying-party ID passkeys are scoped to: the issuer's host, or a registrable domain it is under. Empty serves no passkey ceremony. A public suffix, a domain the issuer is not under, or an IP-address issuer is refused at startup. |
| `mfa.passkey.rp_name` | `Nauthera` | The name an authenticator shows when it creates a credential. |
| `mfa.passkey.origins` | `[]` | The origins (`scheme://host[:port]`) a ceremony may run on, each under `rp_id`. Empty means the issuer's origin alone. |
| `mfa.passkey.user_verification` | `preferred` | `required`, `preferred` or `discouraged`. Under `required`, a ceremony whose authenticator did not verify the user is refused. |
| `mfa.passkey.timeout` | `2m` | How long a ceremony and its challenge last, `30s` to `10m`. |
| `mfa.passkey.offer_interval` | `720h` | Where passwordless sign-in is open, a user who signs in with a password and holds no passkey is offered one. Declining is remembered this long, at least `24h`. |

See [Passkeys](https://www.nauthera.io/docs/v1/sign-in-flows/passkeys).

## Captcha

Unless `auth.captcha.provider` is `none`, registration, password-reset requests and
verification-link resends need a captcha solution, and so does a sign-in once the address
typed, or the caller's IP, has failed `sign_in_after` times recently. A missing, wrong,
expired or reused solution is refused with `400 captcha_failed`.

| Key | Default | Meaning |
|---|---|---|
| `auth.captcha.provider` | `altcha` | `altcha` is the built-in proof-of-work; `turnstile` (Cloudflare Turnstile), `hcaptcha` and `recaptcha` (Google reCAPTCHA v2) are hosted providers; `none` asks for no captcha. |
| `auth.captcha.altcha.max_number` | `1000` | The largest counter a challenge hides, from 1 to 100000. Solving costs the browser `max_number / 2` PBKDF2 derivations on average, and checking costs the server one; the default takes a desktop browser well under a second. |
| `auth.captcha.altcha.validity` | `5m` | How long a challenge can be solved and sent back, from `1m` to `1h`. |
| `auth.captcha.site_key` | `""` | A hosted provider's public site key. Required by a hosted provider. |
| `auth.captcha.secret` | `""` | A hosted provider's secret key, also from `_FILE`. Required by a hosted provider. In the Helm chart, `secrets.captchaSecret`. |
| `auth.captcha.sign_in_after` | `3` | Recent failed sign-ins, by the address typed or from the caller's IP, after which sign-in asks for the captcha too. `0` never asks at sign-in. |

`altcha` calls no third party, so it works air-gapped, and uses only FIPS-approved
primitives. Its challenges are signed with a key derived from `mfa.encryption_key`, so
every replica accepts the others', and each is spent once in the shared cache. A hosted
provider must be reachable from the server (through `HTTPS_PROXY` when set). While it
cannot be reached, every request that needs the captcha is refused with
`503 unavailable`, so pick `altcha` where availability matters more than the provider's
bot scoring.

## Invitations

An administrator can create a user with `invite`, which creates the account without a
password and emails a single-use link to `/accept-invitation`, where the user chooses a
password. The organization needs an email relay. `auth.invitation.validity` (`168h`, from
`1m` to 30 days) is how long a link works, and `auth.invitation.max_requests` (`5`) how
many links one account is sent within one validity window, by an administrator or on the
user's own request; an administrator's resend beyond it is refused with `429`. A user who
waits on an invitation and signs in, signs up, or asks for a reset or verification link is
emailed a new invitation instead (see
[Emailed links](https://www.nauthera.io/docs/v1/sign-in-flows/emailed-links#coming-back-without-the-link)). See
[Admin API & Users](https://www.nauthera.io/docs/v1/user-management).

## The main organization

The main organization (slug `default`) is created by a migration. Each `orgs.main`
setting given is applied to it at every startup, before it is served, and recorded as an
`organization.updated` by the system. The admin API refuses to change a setting given
here (`409`, naming it); one left out stays editable. The control plane never changes the
main organization. In the Helm chart these are `config.orgs.main.*`.

| Key | Meaning |
|---|---|
| `orgs.main.display_name` | The name the hosted UI shows, 1 to 200 characters. |
| `orgs.main.fapi_mode` | Holds the main organization's clients to the FAPI 2.0 Security Profile. It is read only at startup, so this key is the way to change it. Without an ES256 key the server refuses to start in FAPI mode. |
| `orgs.main.email_verification_required` | Keeps an account with an unverified address out of a session until it follows an emailed link. |
| `orgs.main.branding` | The main organization's default branding document, as text (YAML or JSON), because the loader lowercases a nested object's keys. Every sign-in page under it wears it, with a client's own branding laid over it field by field. An unsafe document stops startup; an empty one clears it. |

None of them has a default: left out, the setting is whatever the admin API last set.

External identity providers for social sign-in have no configuration keys. Each
organization declares its own through an
[IdentityProvider](https://www.nauthera.io/docs/v1/identity-provider-resource) resource or `nauthera-cli create
identity-provider`, and the admin API and the dashboard list them.

## Production guards

**Every** issuer must be an `https` URL with a host and no path, query or fragment. There
is no exception for local development: the session cookie's `Secure` attribute follows
the issuer's scheme, so a plaintext issuer would run in a configuration production never
uses. A plaintext listener behind a TLS-terminating proxy is fine, because the issuer
names what clients reach.

When the issuer host is **not** loopback, the server also refuses to start unless:

- `cache.enabled` is `true` (otherwise authorization codes, pushed requests and other
  single-use state would live in one replica's memory), and
- `keys.provider` is `file` and `mfa.provider` is `file` (otherwise keys would be
  regenerated on every restart).

`localhost` and names under `.localhost` count as loopback, so local development gets a
real HTTPS issuer without having to run Redis.

On any issuer, loopback included, `mfa.provider: memory` is also refused when
`mfa.required` is `true`.

Three mistakes are **not** caught at startup:

- A `--config` path that does not exist is ignored, and the server runs on defaults and
  environment variables.
- Without `keys.encryption_key`, each replica only logs a warning and makes up its own
  key in memory. An organization created meanwhile works only on the replica that
  created it, until that replica restarts. See
  [Keys & Secrets](https://www.nauthera.io/docs/v1/keys-and-secrets#the-key-encryption-key).
- An empty secret file is not refused. It blanks its key, and for `keys.encryption_key`
  that means the in-memory key above. See [Secrets from files](#secrets-from-files).

## Example: minimal production file

```yaml
issuer:
  url: https://auth.example.com
database:
  driver: postgres
  host: postgres.example.svc
  user: nauthera
  name: nauthera
  sslmode: verify-full
  migrate: true
cache:
  enabled: true
  address: redis.example.svc:6379
  tls: true
keys:
  provider: file
  signing_key_path: /etc/nauthera/keys/ec/tls.key
  rsa_signing_key_path: /etc/nauthera/keys/rsa/tls.key
mfa:
  provider: file
security:
  trusted_proxies: ["10.244.1.7/32"]
  client_ip:
    mode: x-forwarded-for
  hsts: true
server:
  grpc:
    tls:
      enabled: true
      cert_file: /etc/nauthera/grpc/tls.crt
      key_file: /etc/nauthera/grpc/tls.key
      client_ca_file: /etc/nauthera/grpc/ca.crt
      allowed_clients:
        - identity: "dns:nauthera-operator"
          operations: [clients, brandings, identity_providers, ldap_directories]
          organizations: [default]
          namespaces: ["*"]
audit:
  checkpoint_key_path: /etc/nauthera/audit/checkpoint.pem
  checkpoint_key_required: true
```

Supply the database password, the MFA encryption key and the key-encryption key through
the `_FILE` variables above. Each `allowed_clients` entry must name the `organizations`
and `namespaces` it may act in, or the server refuses to start.
