## Documentation index
This index lists every available documentation page and its Markdown source.
- [Documentation](https://zen-idp.varavel.com/docs/index.md)
- [Get Started](https://zen-idp.varavel.com/docs/get-started/index.md)
- [Installation](https://zen-idp.varavel.com/docs/installation/index.md)
- [Configuration](https://zen-idp.varavel.com/docs/configuration/index.md)
- [Users](https://zen-idp.varavel.com/docs/users/index.md)
- [Clients](https://zen-idp.varavel.com/docs/clients/index.md)
- [Authentication](https://zen-idp.varavel.com/docs/authentication/index.md)
- [Administration](https://zen-idp.varavel.com/docs/administration/index.md)
- [Security](https://zen-idp.varavel.com/docs/security/index.md)
- [Operations](https://zen-idp.varavel.com/docs/operations/index.md)
## Documentation content
The complete documentation for this website follows, reproduced verbatim from every page.
---
# Documentation
Zen IdP is a declarative OpenID Connect identity provider. You describe your users, your applications, and your security policy in YAML, you protect one root secret, and a single binary does the rest: TOTP sign-in, OIDC for your applications, and a small administration interface.
These pages are written for the person who runs Zen IdP, not the person who develops it. Every page explains what something does, why it works the way it does, and what you need to type.
## How to read this documentation
The pages follow the natural order of a deployment. If you are new, read them top to bottom:
1. [Get Started](/docs/get-started/) takes you from nothing to your first working login.
2. [Installation](/docs/installation/) covers Docker images and building from source.
3. [Configuration](/docs/configuration/) documents the complete YAML model, field by field.
4. [Users](/docs/users/) explains how identities are declared and managed.
5. [Clients](/docs/clients/) explains how applications are registered and connected.
6. [Authentication](/docs/authentication/) explains sign-in, enrollment, and recovery.
7. [Administration](/docs/administration/) covers the admin interface and the audit log.
8. [Security](/docs/security/) explains the trust model and where every secret lives.
9. [Operations](/docs/operations/) covers running, upgrading, rotating, and recovering.
If you just want to see it work, start with [Get Started](/docs/get-started/) and come back to the rest when you need it.
## The three moving parts
Everything in Zen IdP reduces to three inputs, and it helps to keep them separate in your head from the beginning:
- **YAML configuration** holds every identity, client, and policy decision. It is the source of truth. If a user is in the YAML, the user exists. You change identity data the same way you change any other code: edit, review, deploy.
- **The root secret** (`ZEN_IDP_SECRET`) is one high-entropy value you keep in your secret manager. Zen IdP derives its signing key and every user's TOTP credential from it, deterministically, on every start. Nothing sensitive is stored to be stolen.
- **The state database** is a single SQLite file that holds sessions, one-use tokens, rate-limit counters, locks, and audit records. It is disposable operational state, not identity data. Lose it and nobody's identity changes, everyone just signs in again.
The rest of this documentation is what those three statements mean in practice.
## Where to go next
- New here? Go through [Get Started](/docs/get-started/).
- Evaluating for production? Read [Security](/docs/security/) and [Operations](/docs/operations/) before you deploy.
- Looking for a specific field? [Configuration](/docs/configuration/) has the complete reference.
---
# Get Started
This walkthrough takes you from an empty machine to a working Zen IdP with one user who can sign in and one application that accepts that sign-in. It uses Docker, which is the quickest way to run a reliable deployment, and a local issuer URL so you can test without a domain or TLS.
Every step below produces something you can see, so you always know where you are. Expect the whole thing to take about fifteen minutes.
## 1. Pull the image
Zen IdP publishes images for amd64 and arm64 on Docker Hub and GitHub Container Registry:
```console
docker pull varavel/zen-idp:0.1.0-alpha.6
```
Pin the exact version you deploy. The `latest` tag only follows stable releases, so pinning keeps upgrades deliberate. See [Installation](/docs/installation/) for the full details, including the GHCR mirror.
## 2. Generate your bootstrap credentials
One command produces everything a fresh deployment needs:
```console
docker run --rm varavel/zen-idp:0.1.0-alpha.6 generate-secrets
```
The output looks like this, with real values instead of the placeholders:
```text
WARNING: This output contains plaintext credentials. Store it securely.
Root secret
ZEN_IDP_SECRET=...
Administrator
plain: ...
hash: "..."
OIDC client
plain: ...
hash: "..."
Important:
- Store plaintext values securely.
- Put only hashes in YAML.
- Never reuse one OIDC client secret or its hash across different clients.
- Each execution creates a completely independent credential bundle.
- When adding another client, use only the new OIDC client section.
- Do not replace the root secret or administrator credentials unless intentionally rotating them.
```
Three values matter right now:
- **Root secret** goes into the environment of the service, never into YAML.
- **Administrator plain and hash** give you access to the admin interface. You sign in with the plain value and put the hash in YAML.
- **OIDC client plain and hash** are the credentials of your first application. The application gets the plain value, the YAML gets the hash.
Save the output now. You will paste parts of it into the configuration and the environment in the next steps.
## 3. Write your configuration
Create a directory for your deployment and a configuration file inside it:
```text
zen-idp/
config/
zen-idp.yaml
```
Open `config/zen-idp.yaml` and start with this:
```yaml
config:
# The public URL of your Zen IdP. HTTP is accepted here because this guide
# runs locally; production issuers must be HTTPS.
issuer: "http://localhost:8080"
# The administrator signs in with the generated plain password.
# Paste the generated administrator hash here, never the plain value.
security:
admin_password_hash: "$argon2id$..."
clients:
# Your first application. It authenticates with the generated client
# plain secret, so it is a confidential client.
- id: "my-app"
name: "My App"
secret_hash: "$argon2id$..."
redirect_uris:
- "http://localhost:3000/callback"
users:
# The smallest valid user. This person signs in with "alice" and a TOTP
# code from their authenticator app.
- sub: "alice"
name: "Alice"
email: "alice@example.com"
```
This is a complete, working configuration. A few things worth noticing:
- `issuer` is the base URL for every endpoint. Localhost with HTTP is fine for this walkthrough. Real deployments use an HTTPS URL behind a reverse proxy.
- The two hashes are the ones `generate-secrets` printed. The application's redirect URI must match exactly what the application will send later, character by character.
- Every field beyond `sub` on a user is optional, including `name` and `email`. See [Users](/docs/users/) for the full model, including custom claims.
## 4. Validate before you run
Make a habit of validating configuration before every deploy. It runs the exact same discovery and validation as startup, so if it passes, `serve` will start:
```console
docker run --rm \
-v ./config:/data/config \
varavel/zen-idp:0.1.0-alpha.6 \
validate-config
```
The image expects configuration in `/data/config` by default, which is why mounting your `config` directory is enough. Validation deliberately does not need the root secret or the database, so you can also run it in CI on every commit.
If validation fails, the error tells you the exact file and the exact problem. Fix it and repeat until you get a clean pass.
## 5. Run the service
Run it with your configuration mounted, the state directory writable, and the root secret in the environment:
```console
docker run -d \
--name zen-idp \
-p 8080:8080 \
-v ./config:/data/config \
-v ./state:/data/db \
-e ZEN_IDP_SECRET="paste the generated root secret here" \
varavel/zen-idp:0.1.0-alpha.6
```
Then check that it is healthy:
```console
curl http://localhost:8080/health
```
The image also ships a built-in health check that runs the same probe every 30 seconds, so your container engine reports the real state of the service. The first start takes a moment because the signing identity is derived from the root secret, but you should see `ok` within seconds.
## 6. Enroll your first user
Users cannot sign in until they have enrolled an authenticator app, and enrollment happens through a one-time link.
1. Open `http://localhost:8080/admin` and sign in with the administrator plain password from step 2.
2. Find your user `alice`, create an enrollment token, and give it a lifetime, for example one hour.
3. The admin interface gives you a one-time enrollment link. Open it in the browser where you will set up the authenticator, or send it to Alice over a channel you trust.
4. The enrollment page shows a QR code. Scan it with any authenticator app, such as Aegis, 1Password, Google Authenticator, or Bitwarden.
5. The link works exactly once. When the QR has been shown, the token is consumed.
The credentials never left your machine: the TOTP secret is derived from your root secret and Alice's subject, encoded into a standard enrollment QR, and shown once to exactly the right person. See [Authentication](/docs/authentication/) for the complete flow, including what to do when someone loses their device.
## 7. Sign in through your application
There is no standalone login page to visit by design. Signing in always happens as part of an application's OIDC flow, which is what makes it single sign-on.
Point your application at Zen IdP with the values it asks for:
| The application asks for | You give it |
| ----------------------------------- | -------------------------------- |
| Issuer, discovery URL, or authority | `http://localhost:8080` |
| Client ID | `my-app` |
| Client secret | the generated client plain value |
| Redirect / callback URL | `http://localhost:3000/callback` |
| Scopes | `openid` is enough |
Open your application, ask it to sign you in, and it will redirect to Zen IdP. Enter `alice` and the six digit code from the authenticator. You come back signed in, and the application has received an ID token with Alice's claims.
The next application you register gets the same treatment, and Alice signs in once for all of them until her session expires.
## 8. What just happened
You now have a complete identity provider:
- Configuration lives in a file you can review and version.
- The root secret derives the signing key and Alice's TOTP credential from thin air, nothing sensitive is stored.
- The state directory holds a SQLite file with Alice's session and nothing else of value.
- The admin interface can issue enrollment tokens for new users, and the audit log records what you did in it.
When you are ready for the real thing, with a domain and TLS, continue with [Installation](/docs/installation/) for deployment details and [Operations](/docs/operations/) for running behind a reverse proxy, upgrades, and backups.
---
# Installation
Zen IdP ships as OCI images on Docker Hub and GitHub Container Registry, and as a Go module you can build from source. Everything the service needs at runtime is inside the image: the binary, the embedded SQLite engine, and the static assets. There is no database server, no broker, and no frontend build.
## Image locations
The same image is published to two registries:
```text
docker.io/varavel/zen-idp:
ghcr.io/varavelio/zen-idp:
```
Both are multi-arch manifests covering `linux/amd64` and `linux/arm64`, so the same tag runs on an x86 server and on something like a Raspberry Pi or an ARM VPS without changes.
Version tags follow the releases, for example `0.1.0-alpha.6`. The `latest` tag only tracks stable releases and never points at a pre-release, so pinning an exact version keeps your upgrades deliberate and your rollback obvious.
## What the image expects
The image is designed so a standard `docker run` or compose file needs almost no configuration:
| Path | Purpose |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/data/config` | Your YAML configuration. The default value of `ZEN_IDP_CONFIG_PATH` points here, and a directory selector reads every immediate `.yaml` and `.yml` child. |
| `/data/db` | The SQLite state database. The default value of `ZEN_IDP_DB_PATH` points to `zen-idp.sqlite3` inside it. |
Both paths are declared as volumes. The process runs as an unprivileged user with UID and GID 65532, so on Linux the mounted directories must be writable by that user:
```console
mkdir -p config state
chown -R 65532:65532 state
```
Configuration is read-only for the service, so the `config` directory only needs to be readable. The port inside the container is 8080 and matches the listener defaults, see [Configuration](/docs/configuration/) if you change either side.
The only input the image does not provide is the root secret, which always comes from your environment or your secret manager.
## Run with Docker
```console
docker run -d \
--name zen-idp \
-p 8080:8080 \
-v ./config:/data/config \
-v ./state:/data/db \
-e ZEN_IDP_SECRET="your root secret, at least 32 characters" \
varavel/zen-idp:0.1.0-alpha.6
```
To check that the service is up, hit the health endpoint:
```console
curl http://localhost:8080/health
```
The image includes a container health check that runs the built-in `zen-idp health` command every 30 seconds, so `docker ps` reports the true state of the service, not just the state of the process.
## Run with Docker Compose
A compose file makes the deployment reproducible, which is worth it even for a single service:
```yaml
services:
zen-idp:
image: varavel/zen-idp:0.1.0-alpha.6
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./config:/data/config
- ./state:/data/db
environment:
ZEN_IDP_SECRET: "${ZEN_IDP_SECRET}"
```
Put the root secret in an `.env` file next to the compose file, keep that file out of version control, and Compose loads it automatically:
```dotenv
ZEN_IDP_SECRET=your-root-secret
```
Binding to `127.0.0.1:8080` keeps the service reachable only from the host itself, which is exactly what you want when a reverse proxy on the same machine terminates TLS. If the proxy runs in another container, replace the binding with a shared network and no published port at all.
## Build from source
If you prefer to build the binary yourself, all you need is Go:
```console
git clone https://github.com/varavelio/zen-idp.git
cd zen-idp
go build -o zen-idp ./cmd/zen-idp
```
The project repository also defines a Taskfile with the same commands plus its development checks:
```console
task build
```
The resulting binary is self-contained and works anywhere the Go toolchain targets. When you run it outside Docker, the same three environment variables apply, and the paths point wherever you point them. See [Operations](/docs/operations/) for the complete runtime reference.
## Upgrades
Upgrading is replacing the image tag, with one safety habit:
1. Run `validate-config` with the new image and your current configuration. This catches schema changes before they reach your running service.
2. Change the image tag and recreate the container.
3. Confirm the health endpoint returns `ok`.
The state database carries forward. Ordinary upgrades and restarts preserve unexpired sessions, outstanding enrollment tokens, locks, and rate-limit counters, so nobody has to sign in again and nothing you revoked becomes valid again. If an upgrade ever changes the state schema, it is migrated automatically when the service starts.
---
# Configuration
Zen IdP is configured entirely through YAML. One document, or a set of documents you can split as you like, describes every user, every application, and every policy decision. This page documents the complete model and how files are discovered and merged.
If you want the fastest possible introduction instead, [Get Started](/docs/get-started/) shows a minimal working file.
## The three blocks
Every configuration document has exactly three top-level blocks:
```yaml
config:
# Instance settings: issuer, listener, UI, security, maintenance.
clients:
# The applications that trust this identity provider.
users:
# The human identities.
```
Nothing else is allowed at the top level, and runtime inputs such as the root secret and the database path never belong in these files. They live in the environment, see [Operations](/docs/operations/).
The reference file [`config.example.yaml`](https://github.com/varavelio/zen-idp/blob/main/config.example.yaml) in the repository documents every field with comments and is a good starting template.
## `config`: the instance
### `config.issuer`
The public URL of this Zen IdP, for example `https://auth.example.com`. It is the OIDC issuer value, the base for every endpoint URL, and the `iss` claim in tokens. It must be an absolute URL with no userinfo, query, or fragment.
In production it must use HTTPS. Plain HTTP is accepted only for local development, when the host is `localhost` or a loopback IP address. Everything derives from this value, so changing it later is the same as moving your identity provider to a new address: every application must be updated to point at the new issuer.
### `config.server`
Optional listener settings:
| Field | Default | Meaning |
| ------ | ----------- | --------------------------------------------------------------------------- |
| `host` | `"0.0.0.0"` | Address to bind. Use `127.0.0.1` to keep the listener local to the machine. |
| `port` | `8080` | TCP port, from 1 to 65535. |
Zen IdP serves plain HTTP on this listener and never terminates TLS itself. Production deployments put a reverse proxy or CDN in front and forward traffic to this listener. The proxy must send the original scheme in `X-Forwarded-Proto` so cookies and redirects match the public HTTPS issuer.
### `config.ui`
Optional presentation settings for the pages your users see:
| Field | Meaning |
| ---------------- | ------------------------------------------------------------------- |
| `name` | Product or organization name shown on sign-in and enrollment pages. |
| `logo_light_url` | Logo for light mode, an absolute HTTPS URL. |
| `logo_dark_url` | Logo for dark mode, an absolute HTTPS URL. |
| `favicon_url` | Favicon, an absolute HTTPS URL. |
Setting the same URL for both logos shows one logo in both modes. When only one is set it is used everywhere. These settings change what users see, never how authentication works.
### `config.security.admin_password_hash`
Required. The Argon2id hash of the administrator password. It gates the admin interface, which can create enrollment tokens, so treat it as a powerful credential: generate it with `zen-idp generate-secrets`, keep the plain value in your password manager, and put only the hash in YAML.
The administrator password is independent from the root secret. Rotating it means updating this hash.
### `config.security.rate_limits`
Optional failed-attempt limits. All of them are keyed by identifier, never by source IP, and unknown identifiers are bounded exactly like known ones so nobody can tell them apart from the outside.
| Field | Default | Meaning |
| ------------------------------------- | ------- | ---------------------------------------------------- |
| `max_user_login_attempts` | `5` | Failed sign-in attempts allowed per identifier. |
| `user_login_attempts_window_seconds` | `300` | Window for those attempts. |
| `max_client_auth_attempts` | `5` | Failed client authentications allowed per client ID. |
| `client_auth_attempts_window_seconds` | `300` | Window for those attempts. |
A user's two identifiers, `sub` and `idp_login`, share one counter, so an attacker cannot get extra attempts by alternating between them. Administrator sign-in is rate limited with the same model. See [Security](/docs/security/) for the reasoning behind identifier-based limits.
### `config.security.session.max_age_hours`
Optional absolute lifetime of the browser session, in hours, from 1 to 8760. The default is 72. This is the Zen IdP single sign-on session, the thing that keeps users signed in across your applications. It is independent from the tokens your applications issue themselves: when the Zen IdP session expires, the next sign-in needs a fresh TOTP code, but applications keep their own local sessions alive on their own terms.
### `config.maintenance`
Optional background cleanup settings:
| Field | Default | Meaning |
| -------------------------- | ------- | ------------------------------------------------------------------------------ |
| `cleanup_interval_seconds` | `3600` | Seconds between cleanup passes, from 60 to 86400. A pass also runs at startup. |
| `audit_retention_hours` | `720` | Hours audit records are kept before removal. Zero keeps them indefinitely. |
Cleanup removes state that can never become usable again: expired rate-limit counters, expired one-use tokens, expired sessions, and audit records older than the retention window.
## `clients`: your applications
Each list item registers one application. The complete field set:
| Field | Required | Meaning |
| --------------- | -------- | ------------------------------------------------------------------------------------ |
| `id` | Yes | The OIDC `client_id`. Nonblank, unique. |
| `name` | No | Display name, defaults to `id` when omitted. |
| `secret_hash` | No | Argon2id hash of the client secret. Present means confidential, absent means public. |
| `redirect_uris` | Yes | One or more exact callback URIs. |
Applications that can keep a secret, such as a server side app, use `secret_hash` and authenticate with it. Applications that cannot, such as a mobile app or a SPA, omit it and use PKCE instead. The rules for redirect URIs, and guidance on which kind to choose, are on the [Clients](/docs/clients/) page.
## `users`: your identities
Each list item is one user mapping. The required minimum is a `sub`:
```yaml
users:
- sub: "alice"
```
Every other field is optional. Recognized internal fields are `idp_login`, `idp_totp_rev`, and `idp_expires_at`, and anything else becomes a custom OIDC claim released to all your applications. The complete model, including claims and the reserved names, is documented on the [Users](/docs/users/) page.
## Splitting configuration across files
The environment variable `ZEN_IDP_CONFIG_PATH` selects your configuration with exactly one selector, which can be:
- **one file**, for example `config/zen-idp.yaml`;
- **one directory**, in which case every immediate `.yaml` and `.yml` child is selected, without recursion;
- **one glob**, for example `config/**/*.yaml`, when you want depth.
Relative selectors resolve from the working directory of the process, so the same relative selector behaves the same in Docker, systemd, or a shell. Selected files are sorted and deduplicated deterministically, then composed into one configuration:
- Mappings merge recursively, so `config/zen-idp.yaml` and `config/security.yaml` can each define parts of `config`.
- Lists are appended in file order, so `users` can live in several files.
- Two definitions of the same thing, such as two different values for `issuer`, or the same `sub` in two files, fail validation instead of silently winning.
Splitting is free, so let your structure follow your organization: a file per team, a generated file for onboarding batches, or a single file for a small deployment. There is no performance difference.
## Validation
Configuration is fully parsed and validated before the service accepts traffic, and invalid configuration fails startup with the file and the reason. The same path runs on demand:
```console
zen-idp validate-config
```
It needs only `ZEN_IDP_CONFIG_PATH`, so it fits naturally in CI: run it on every commit that touches configuration and mistakes never reach a deployment. Validation is strict about unknown fields and wrong types on purpose: a typo that silently does nothing is worse than an error.
## Runtime inputs are not configuration
Three values come from the environment and are rejected if they appear in YAML: `ZEN_IDP_CONFIG_PATH`, `ZEN_IDP_SECRET`, and `ZEN_IDP_DB_PATH`. Keeping infrastructure inputs and identity data apart is what lets you commit your YAML to version control while the secrets stay in a secret manager. The complete runtime reference, including explicit env files and precedence rules, is on the [Operations](/docs/operations/) page.
---
# Users
Every identity in Zen IdP is one YAML mapping. There is no user database behind it, no profile editor, and no sync job: a person exists exactly when their mapping exists, with exactly the fields you gave them. This page documents every field and the lifecycle operations you perform day to day.
## The subject: `sub`
Required, and the most important value you will choose for each person:
```yaml
users:
- sub: "alice"
```
`sub` is the stable OIDC subject, the identifier every application receives as the `sub` claim. It becomes the user's primary login identifier and the input from which their TOTP credential is derived, which gives it two properties worth internalizing:
- **It is permanent in practice.** Changing a `sub` creates a new identity: a new login, a new TOTP secret, and a new `sub` claim in your applications. Never reuse a subject for a different person, and pick values that survive role changes. `alice` ages better than `alice-marketing`.
- **It follows rules.** One to 255 US-ASCII characters, case-sensitive, unique. It does not need to be an email address. Quoted YAML strings are required, since numbers and booleans are rejected rather than converted.
## The second identifier: `idp_login`
Optional. When present, the user can sign in with either identifier:
```yaml
users:
- sub: "user-001"
idp_login: "alice@example.com"
```
Both values authenticate the same identity, and both can always sign in: adding `idp_login` never disables `sub`. It is a single string, not a list, and it is never released as a claim. It exists for the common case where people remember their email but not their username.
All identifiers share one namespace across all users. Two users cannot have the same `sub`, cannot have the same `idp_login`, and one user's `idp_login` cannot collide with another user's `sub`. Validation fails on any collision, so what you write is exactly what can sign in.
## Custom claims
Any field that is not `sub` or one of the internal `idp_` fields becomes a custom claim:
```yaml
users:
- sub: "user-001"
name: "Alice Example"
email: "alice@example.com"
groups:
- "engineering"
- "operators"
profile:
department: "Platform"
active: true
```
Claims can be strings, numbers, booleans, arrays, and nested objects, and they are emitted exactly as written in every ID token and every UserInfo response. Zen IdP never invents values, never fills in `name` or `email` on its own, and missing data stays missing.
Your applications read these claims and decide what the user may do. Zen IdP is deliberately not an authorization engine: it answers who is signing in, your applications answer what that person can touch. A typical setup declares `groups` or `roles` here and lets each application map them to its own permissions.
Two kinds of names are reserved and rejected with a validation error:
- every key starting with `idp_`, that namespace is internal;
- protocol claims such as `iss`, `sub`, `aud`, `exp`, `iat`, `nonce`, and `auth_time`, which Zen IdP produces itself.
## Expiration: `idp_expires_at`
Optional. An absolute instant after which the user can no longer authenticate:
```yaml
users:
- sub: "contractor-23"
idp_expires_at: "2026-12-31T23:59:59Z"
```
Write it as a quoted RFC 3339 timestamp. At and after that instant, the user cannot start or complete a sign-in, existing sessions stop working, and UserInfo stops answering for them. Enforcement is immediate and continuous, not tied to a cleanup job.
This is the right tool for contractors, auditors, and temporary staff: the account simply stops existing as an authentication option when the engagement ends. ID tokens already issued remain valid until their own short expiry, which applications handle through their normal token lifetimes.
## TOTP revision: `idp_totp_rev`
Optional, defaults to 0. Incrementing it gives that one user a brand new TOTP credential:
```yaml
users:
- sub: "user-001"
idp_totp_rev: 2
```
Each user's authenticator secret is derived deterministically from the root secret, their `sub`, and this revision. When you increment the revision, the old secret stops working, every session authenticated with it is invalidated, and the user needs a new enrollment link. Nobody else is affected, and the signing key does not change.
This is the standard response to a lost device or a suspected leak, and the full recovery walkthrough is on the [Authentication](/docs/authentication/) page. The field is never released as a claim.
## Lifecycle operations
Because identities are files, every lifecycle operation is an edit followed by a deploy. Configuration activates when the service restarts or starts with the new files.
| Operation | What you do |
| -------------------- | -------------------------------------------------------------------------------------------- |
| Add a user | Add the mapping, deploy, then create an enrollment token in the admin interface. |
| Remove a user | Delete the mapping and deploy. Their sessions stop working immediately. |
| Disable temporarily | Lock the user from the admin interface instead, see [Administration](/docs/administration/). |
| Disable permanently | Remove the user, or set `idp_expires_at` to a past instant, and deploy. |
| Change claims | Edit the mapping and deploy. New tokens carry the new claims right away. |
| Rotate credentials | Increment `idp_totp_rev`, deploy, create a new enrollment token. |
| Rename an identifier | Change `idp_login` freely. Changing `sub` is creating a new identity, prefer not to. |
Removing a user or letting them expire does not recall ID tokens your applications already accepted. Those tokens are short lived by design, and each application's own session decides how quickly access actually disappears. High-security removals combine the YAML change with an administrative lock, which also revokes every session at once.
## Sizing expectations
Hand-maintained YAML is comfortable from a handful of users into the hundreds. Beyond that, let tooling write the files: because configuration composes deterministically, a script or template that generates a batch of users into a separate file works as well as typing them by hand, and `validate-config` in CI catches generation mistakes before they deploy. Zen IdP is intentionally not a directory service with connectors and sync jobs, see [Security](/docs/security/) for where its boundaries are.
---
# Clients
Every application that signs people in through Zen IdP is a client, and registering one is a few lines of YAML. This page explains the client model, how to choose between a confidential and a public client, and how to point real applications at your identity provider.
## Confidential or public
The first decision for each client is whether the application can keep a secret:
- A **confidential client** runs somewhere the secret is safe, typically a server side application. It gets a client secret from you and proves its identity with it on every token exchange.
- A **public client** runs somewhere hostile, typically a mobile app, a desktop app, or a SPA. It has no secret. Instead it proves the continuity of its own login flow with PKCE, which is mandatory for public clients.
Both kinds receive the same tokens and the same claims. The choice is about the application's environment, not its importance.
## Registering a client
```yaml
clients:
- id: "grafana-prod"
name: "Grafana"
secret_hash: "$argon2id$..."
redirect_uris:
- "https://grafana.example.com/login/generic_oauth"
```
The complete field set:
| Field | Required | Meaning |
| --------------- | -------- | ------------------------------------------------------------------------------ |
| `id` | Yes | The `client_id` the application sends. Nonblank and unique. |
| `name` | No | Display name shown in the interface. Defaults to `id`. |
| `secret_hash` | No | Argon2id hash of the client secret. Present is confidential, absent is public. |
| `redirect_uris` | Yes | One or more exact callback URIs. |
Generate client secrets with `zen-idp generate-secrets`, which prints a plain value for the application and a hash for your YAML. Like every credential in Zen IdP, only the hash is ever stored.
A public client is simply one without `secret_hash`:
```yaml
clients:
- id: "mobile-app"
redirect_uris:
- "com.example.app:/oauth/callback"
```
## Redirect URI rules
The redirect URI is where Zen IdP sends the browser after login, and it is treated as a security boundary:
- Matching is **exact**, character by character. No wildcards, no partial matches, no normalization. `https://app.example.com/callback` and `https://app.example.com/callback/` are different URIs.
- URIs must be **absolute**, and must not contain a fragment.
- Production URIs use **HTTPS**. Plain HTTP is accepted only for development on `localhost` or a loopback IP.
- Public clients may also register a **custom scheme** in reverse-domain notation with at least one dot, the convention for native app callbacks such as `com.example.app:/oauth/callback`. Confidential clients stick to HTTPS URIs.
Copy redirect URIs from the application's own configuration rather than typing them from memory. A trailing slash or a different case is the single most common reason an app refuses to start its login flow.
## Connecting an application
Any OIDC-capable application connects the same way. Somewhere in its settings it will ask for:
| The application asks for | You give it |
| ----------------------------------- | ------------------------------------------------------------ |
| Issuer, authority, or discovery URL | Your `config.issuer`, for example `https://auth.example.com` |
| Client ID | The `id` you registered |
| Client secret | The generated plain value, confidential clients only |
| Redirect / callback URI | One of the exact URIs you registered |
| Scopes | `openid` |
Applications that discover their configuration from `/.well-known/openid-configuration` need only the issuer and their credentials, since endpoint URLs, signing algorithms, and capabilities are advertised automatically.
Some applications ask whether to use PKCE. The answer is yes whenever it is offered, for both public and confidential clients. PKCE costs nothing and closes a class of interception attacks. If the application cannot keep a secret at all, register it as a public client and make sure it sends a PKCE challenge, because Zen IdP requires `S256` for public clients.
## What applications experience
The flow is the standard OIDC Authorization Code Flow:
1. The application sends the browser to `/authorize` with its client ID, redirect URI, and state.
2. The user signs in with their identifier and TOTP code, reusing an existing session when one is alive.
3. The browser returns to the application with a one-time authorization code.
4. The application exchanges the code at `/token`, authenticating with its secret if it is confidential and with its PKCE verifier if it used PKCE.
5. It receives an ID token with the user's `sub` and all their custom claims, plus a short lived access token valid for Zen IdP's `/userinfo` endpoint.
A few behaviors are worth knowing in advance:
- **There is no consent screen.** Registering a client is your decision, as the operator, that this application may receive claims. Users go straight through.
- **Scopes are simple.** `openid` is required, other scope names are accepted and echoed back for compatibility, but they never change which claims are released. `offline_access` will not produce a refresh token, because Zen IdP does not issue refresh tokens at all.
- **The access token is for userinfo only.** It is a thin, signed token whose only audience is `/userinfo`. Applications that try to use it as a general API token against other services will not get far; it is not that kind of token.
See [Authentication](/docs/authentication/) for what the user experiences inside step 2, and [Security](/docs/security/) for the reasoning behind the token design.
## Managing clients over time
| Operation | What you do |
| ----------------------- | ------------------------------------------------------------------------------------ |
| Add an application | Run `generate-secrets`, register the client, deploy, paste credentials into the app. |
| Rotate a client secret | Generate a new bundle, replace the hash in YAML, deploy, update the app. |
| Remove an application | Delete the mapping and deploy. New logins for it stop immediately. |
| Fix a redirect mismatch | Add the exact URI the application sends, deploy. |
| Rename the display name | Edit `name`. Protocol identity is the `id`, which stays. |
Removing a client does not end sessions users already have in that application. The application decides its own session lifetime, and its local sign-out is the reliable way to end access there. What removal does guarantee is that the application can never complete a new login or exchange a new code.
---
# Authentication
This page explains what signing in actually is in Zen IdP: how users get credentials, how they use them, what happens when a device is lost, and how sessions behave. It is written for both operators and anyone answering user questions.
## Credentials that are derived, not stored
Every user signs in with two things they have: an identifier and a six digit code from an authenticator app. The code is a standard TOTP value, RFC 6238, with a 30 second step, generated by any authenticator: Aegis, 1Password, Bitwarden, Google Authenticator, or the one built into your password manager.
The interesting part is where the shared secret comes from. There is no table of secrets anywhere. Each user's secret is derived on demand from two inputs:
- the root secret you keep in your environment, and
- the user's `sub` and their current TOTP revision.
The same two inputs always produce the same secret, on every restart, forever. That is what makes recovery in this system so short: nothing to back up, nothing to migrate, nothing to restore. It also means the root secret is the one value that protects every credential, which is why [Security](/docs/security/) spends so much time on it.
## Enrollment: giving a user their first credential
A user cannot sign in until they have scanned their secret into an authenticator, and that happens through a one-time link:
1. An administrator opens the admin interface, finds the user, and creates an enrollment token with a lifetime, for example one hour.
2. The admin interface produces a single link, valid once, bound to that user and their current revision.
3. The user opens the link and sees a QR code and the secret in text form.
4. They scan it with their authenticator. The app now generates the right codes.
5. The link is consumed the moment the secret is shown. Opening it again does not work.
Two properties make this safe by default. The link works exactly once, so a copy circulating in a chat log is dead after first use. And the secret is only ever revealed to whoever holds the unexpired, unconsumed link, which is why you deliver it over a channel you trust for that person.
Enrollment links expire quickly on purpose. If a link goes stale, create a new one, they are free. If you suspect a link was opened by the wrong person, see recovery below.
## Signing in
Sign-in always happens inside an application's login flow, which is what makes it single sign-on:
1. The user clicks sign-in in any connected application and is redirected to Zen IdP.
2. If they have a live session, they are sent straight back, signed in.
3. Otherwise they enter their identifier, which is their `sub` or their `idp_login` when one is configured, and the current code from their authenticator.
4. On success, a session is created and the browser returns to the application, carrying an authorization code it exchanges for tokens.
Codes from the authenticator are accepted within a small clock tolerance window of one step in each direction, so a phone a few seconds behind still works. Failed attempts are rate limited per identifier, five attempts by default inside five minutes, and the limit applies to both of a user's identifiers as one bucket. When the limit trips, sign-in for that identifier is throttled for the rest of the window.
An accurate clock is a hard requirement on both sides: the server needs reliable time to validate codes, and the user's device needs it to generate them. If everyone's codes are suddenly rejected, check server time first.
## Sessions
After a successful sign-in, the browser holds one Zen IdP session, valid across all your applications for the configured lifetime, 72 hours by default. That is the single sign-on part: the second application redirects, sees the live session, and sends the user back signed in without asking anything.
A few facts that answer most session questions:
- **Signing out of Zen IdP** is an action the application or the user can trigger, and it revokes the session server side. It does not sign users out of applications' own local sessions, those belong to the applications.
- **Signing out of an application** usually ends only that application's session. The Zen IdP session may still be alive, so the next sign-in there is silent.
- **Any change that matters revokes sessions.** Removing the user, letting them expire, incrementing their TOTP revision, locking them, or their own panic action all end the session immediately.
- **Applications' tokens are short and independent.** ID tokens and access tokens live 15 minutes and stay mathematically valid until then even if the session is revoked. Applications that need stronger revocation check `/userinfo`, which enforces session state, or keep short local sessions.
## Recovery when a device is lost
People lose phones. Recovery is a short, well-defined procedure, and every step is something you already know how to do:
1. **Stop the bleeding if needed.** Lock the user from the admin interface. This ends their sessions and blocks sign-in until you unlock.
2. **Rotate the credential.** Increment the user's `idp_totp_rev` in YAML and deploy. The old secret is now wrong, on the lost device and anywhere else it might have leaked.
3. **Enroll the new device.** Create a fresh enrollment token, deliver it, have the user scan it.
4. **Unlock** if you locked them in step 1.
Total effort: one number changed, one deploy, one link. No user is ever "locked out forever", because credentials are derived from configuration, not stored in a database you would have to repair.
The same procedure, minus the urgency, is how you rotate a credential proactively when someone changes devices or when policy says so.
## The panic action
Sometimes it is the user who notices something wrong, a session they do not recognize, a phone gone missing. For that, any signed in user can open `/panic`, for example `https://auth.example.com/panic`, and confirm the emergency action.
Invoking it does two things at once, atomically: every session of that user is revoked, including the one they are using, and a temporary panic lock blocks any new sign-in. The user ends signed out and the account is frozen.
The panic lock is deliberate friction, not a punishment: it can only be cleared by an administrator after the organization's checks, which for most deployments means verifying the person and walking them through enrollment on a new device. See [Administration](/docs/administration/) for the admin side of locks.
---
# Administration
Zen IdP has a deliberately small admin interface. Identity data never changes through it, that is what YAML is for, so what remains are the four operational actions that genuinely belong behind a strong password: issuing enrollment tokens, locking and unlocking users, clearing panic locks, and reading the audit log.
## Signing in
The admin interface lives at `/admin` under your issuer, for example `https://auth.example.com/admin`. Sign in with the administrator password whose Argon2id hash is configured as `config.security.admin_password_hash`.
Two things make this credential worth protecting:
- It is independent from the root secret. Compromising one does not compromise the other, and rotating it is a YAML edit.
- An administrator can create enrollment tokens, and an enrollment token reveals a user's TOTP secret to whoever redeems it. Treat the admin password as an identity impersonation capability and store it accordingly.
Administrator sessions are separate from user single sign-on sessions, so signing into the admin interface never creates a user session and vice versa. Failed administrator sign-ins are rate limited with the same model as user sign-ins, keyed by identifier rather than IP.
## Enrollment tokens
The primary admin action. Select a user, choose an expiration, and the interface produces a one-time enrollment link:
- The expiration can be given as a **duration from now**, such as one hour, or as an **absolute deadline**, whichever is more convenient. Both are normalized to one absolute expiry, which must be in the future.
- The token is **bound to the user and their current TOTP revision**. If you increment the revision after creating it, the link is dead, create a new one.
- The link is **consumed exactly once**, at the moment the credential is revealed. Sending it again does nothing.
Delivery is your choice, and the only rule is trust: deliver the link over a channel you trust for that specific person, because whoever opens it first gets the secret. For most teams that is a direct message to a known account, never a shared channel.
If a link leaks and might have been redeemed by the wrong person, do not hunt for proof: increment that user's `idp_totp_rev`, deploy, and enroll them again. The old secret becomes wrong immediately, which is strictly safer than trying to figure out who saw it.
## Locks
Locks are the temporary gate between "we are looking into something" and the permanent decisions that live in YAML. The admin interface offers lock actions per user:
**Administrative lock.** Immediately revokes every session of that user and blocks new sign-ins. Use it when you need access to stop now but removal to be a separate, considered decision. Unlocking is one click and restores sign-in, provided the user is still declared and unexpired.
**Panic lock.** Created by the user themselves through the panic action, described in [Authentication](/docs/authentication/). Clearing it is an explicit admin decision after your checks, and it is never cleared implicitly by unlocking an administrative lock or editing YAML.
Two properties matter operationally:
- Locks are **operational state, not identity data**. They live in the SQLite state file and do not survive losing it. Durable disablement always goes through YAML: remove the user or set an expiration.
- Locking and revoking happen **atomically**. There is no window where a lock exists but old sessions still work.
When you want someone gone for good, the reliable sequence is: lock first if you want instant effect, then remove them from YAML and deploy. After the deploy, the user no longer exists regardless of any state file anywhere.
## The audit log
Find it at `/admin/audit`. Every security relevant action is recorded: administrator sign-ins, enrollment token creation and consumption, lock changes, panic actions, session revocations, and rate limit events. Records show what happened, when, and to whom.
What the log deliberately does not contain is secrets. No passwords, no TOTP secrets or codes, no tokens, no complete cookies. A leaked audit page is embarrassing, not fatal.
The log is operational, not archival:
- Records live in the SQLite state file and are purged on a retention schedule, 30 days by default, configurable through `config.maintenance.audit_retention_hours`.
- Losing or resetting the state file loses the log with everything else in it.
If your organization needs audit history that survives, export the events you care about to your logging platform as they happen, rather than treating this view as long term storage.
## What the admin interface cannot do
The list of things it cannot do is as intentional as the list of things it can:
- It cannot create, edit, or delete users. Identity lives in YAML and changes through review.
- It cannot change client registrations or any other configuration.
- It cannot reveal root derived keys, signing keys, or stored hashes.
- It cannot increment a user's TOTP revision, that is a YAML change by design, so credential rotation always leaves an audit trail in your repository.
The result is an interface where the worst case for a compromised administrator password is bounded and visible: enrollment links, temporary locks, and readable history, all of it recorded.
---
# Security
This page explains how Zen IdP thinks about security, so your deployment decisions can follow the same logic. It is organized around a simple question: where does each kind of value live, and what happens if it leaks?
## The trust model
Zen IdP answers one question: **who is signing in**. It authenticates humans and asserts verified claims to your applications. What those people may do is answered by each application, using the claims it receives. Groups and roles travel in tokens, permissions stay where they belong, in the applications.
Keeping that boundary is a design decision with consequences you can feel: no permission screens, no policy engine, no per-application claim filtering. Every client receives every custom claim of the signing-in user, which is why claims are perfect for `groups: [engineering]` and wrong for anything you would not hand to every app at once.
## Three places, three kinds of value
Everything in the system lives in exactly one of three places, and knowing which is which is most of the security model:
| Where | What lives there | If it leaks |
| --------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| YAML configuration | Users, clients, claims, policy, and Argon2id hashes | Offline guessing of your administrator and client passwords becomes possible. Strong generated values and limited access to the repository are the answer. |
| The root secret, `ZEN_IDP_SECRET` | The input from which the signing key and every TOTP credential derive | Every identity can be impersonated. It never belongs in YAML, logs, or the database, and rotation is a major event, see below. |
| The SQLite state file | Sessions, one-use tokens, rate-limit counters, locks, audit records | Sessions and outstanding enrollment links can be analyzed and, with the right tools, possibly abused. No credentials or private keys are inside, and a fresh state file ends the exposure. |
The design goal of that split is that the most valuable thing, the root secret, exists in exactly one place you control, and everything else is either public by nature (the public signing key) or disposable (the state).
## The root secret
One value, supplied through the environment, from which two families of secrets derive deterministically:
- the **OIDC signing identity**, an RSA key pair that signs every token, always the same pair for the same secret;
- every **user's TOTP credential**, derived from the root secret, the user's `sub`, and their revision.
Derivation is domain separated and deterministic, which yields the two properties that define Zen IdP operationally: restarts reproduce everything identically without stored key material, and rotating a single user's credential is a one field YAML change.
With that power comes exactly one obligation: the secret must be **high entropy**. It must be at least 32 characters, and it must come from a generator, `generate-secrets` produces 256 bits of entropy. A human sentence as a root secret is a master key with a bad passphrase.
Rotation of the root secret is a globally disruptive event by construction: the signing identity changes, every user's TOTP credential changes, all authenticators must re-enroll, and previously issued tokens stop validating against the new public key. That is not a flaw, it is what "one secret protects everything" means. Plan it, schedule it, and communicate it.
## Token design
Tokens issued to applications are deliberately short lived:
| Token | Lifetime | Contents |
| ------------------ | ---------- | ----------------------------------------------------------------------------------- |
| ID token | 15 minutes | Signed identity assertion with `sub`, audience, and every custom claim of the user. |
| Access token | 15 minutes | Thin, signed, audience restricted to `/userinfo`. Contains no profile claims. |
| Authorization code | 5 minutes | One-time code bound to client, redirect URI, subject, and PKCE challenge when used. |
Two design choices deserve the reasoning behind them:
- **There are no refresh tokens.** Short sessions and a fresh sign-in when they expire keep the attack surface small and the mental model simple. Applications that want persistent access keep their own local session and walk the user through a silent re-login when needed.
- **The access token is thin on purpose.** It answers exactly one question at one endpoint: who does this bearer belong to, right now. Applications that need authoritative, current claims call `/userinfo`, which validates the token and then resolves the user against the live configuration, enforcing sessions, locks, expiration, and the current claims in YAML. Treating the access token as a general API key against other services will not work, it was not built for that.
PKCE with the `S256` method is mandatory for public clients and recommended for confidential ones, closing code interception attacks in both cases. Client authentication supports `client_secret_basic` and `client_secret_post` for confidential clients, and `none` for public clients.
## Rate limiting and abuse resistance
Failed attempts are limited per identifier, not per IP:
- A known user's `sub` and `idp_login` share a single counter, so alternating identifiers grants no extra attempts.
- Unknown identifiers are limited too, with responses indistinguishable from other failures, so enumeration through error differences is impractical.
- Administrator sign-in and client authentication have equivalent limits.
IP based limits are deliberately absent from the service: behind proxies and NAT they punish shared users and barely slow distributed attackers. That control belongs at your edge, where the real client addresses are known. A CDN or reverse proxy with IP limits in front, plus Zen IdP's identifier limits behind it, is the intended combination.
## Transport and browser security
Zen IdP serves plain HTTP and expects TLS to terminate in front of it, which is the standard pattern and the one that keeps certificate management out of the service. The obligations are small and specific:
- The issuer URL must be HTTPS in production, and everything, discovery, redirects, cookies, token claims, derives from that value rather than from request headers that could be forged.
- Your proxy must forward the original scheme in `X-Forwarded-Proto`. Zen IdP trusts exactly that header to recognize the public scheme.
- Cookies are `HttpOnly` and, in production, `Secure`, with a strict same-site policy. Session cookies hold opaque high entropy credentials, never self-contained identity, and a database row without its cookie is useless.
- State changing browser actions are CSRF protected, pages handling authorization and enrollment set `no-store` and `no-referrer`, and request sizes are bounded.
## What is deliberately not there
An honest boundary list is part of trusting a security tool, so here is what Zen IdP does not attempt in version 1:
- **TOTP is not phishing resistant.** A convincing fake login page can relay a live code. TLS everywhere and user habit are the mitigations available today.
- **A code is accepted for one step of skew in each direction**, and a captured code can be replayed inside that window. Rate limiting and short windows bound the practical risk.
- **Issued ID tokens are not revocable.** They are valid for up to 15 minutes, and `/userinfo` or the session check is the revocation-aware path.
- **The audit log is operational history, not compliance storage.** Retention is finite and tied to the state file. Export events you must keep.
- **One active instance per issuer.** The state file is local SQLite, and multiple instances would not share sessions, locks, or counters coherently.
- **Administrator authority includes enrollment**, which means it includes impersonation. Protect the admin password accordingly and watch the audit log.
None of these are accidents. Each one trades a capability most small deployments do not need for a system that is smaller, easier to reason about, and safer to operate.
---
# Operations
This page is the runtime reference: what the process needs, what it exposes, how to run it behind a proxy, what the state database means for operations, and every rotation and recovery procedure in one place.
## Runtime inputs
Three environment variables configure the runtime. They never belong in YAML:
| Variable | Used by | Meaning |
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
| `ZEN_IDP_CONFIG_PATH` | `serve`, `validate-config`, `health` | One selector: a file, a directory, or a glob. See [Configuration](/docs/configuration/). |
| `ZEN_IDP_SECRET` | `serve` | The root secret, at least 32 characters, high entropy. |
| `ZEN_IDP_DB_PATH` | `serve` | Path of the SQLite state file, created and migrated on start. |
A relative config selector resolves from the working directory of the process. In the published image the defaults are already set to the conventional locations: configuration at `/data/config` and the database at `/data/db/zen-idp.sqlite3`.
To load values from a file, pass it explicitly:
```console
zen-idp serve --env-file ./production.env
```
The file is a simple `KEY=value` list. Two rules keep behavior predictable:
- Zen IdP never loads `.env` or any other file implicitly. No file is read unless you name it.
- Values already present in the process environment win, even when they are empty. An empty override fails validation loudly instead of silently falling back to the file.
## Commands
```text
zen-idp serve [--env-file PATH]
zen-idp validate-config [--env-file PATH]
zen-idp generate-secrets
zen-idp health [--env-file PATH]
```
| Command | What it does |
| ------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `serve` | Starts the service. Requires all three variables and completes configuration validation before accepting traffic. |
| `validate-config` | Runs the exact startup validation and exits. Needs only the configuration. Run it in CI and before every deploy. |
| `generate-secrets` | Prints an independent bootstrap bundle: root secret, administrator pair, OIDC client pair. Needs nothing. |
| `health` | Checks the configured listener and prints `ok`. This is what the container health check runs. |
`generate-secrets` writes everything to standard output, including its own warnings. Every run is an independent bundle: when you add a second client, use only the new OIDC client section of the new output and leave the root and administrator values alone unless rotating them.
## HTTP endpoints
Under your issuer, the public surface is:
| Endpoint | Purpose |
| ----------------------------------- | --------------------------------------------------------- |
| `/.well-known/openid-configuration` | OIDC discovery document. |
| `/.well-known/jwks.json` | Public signing keys. |
| `/authorize` | OIDC authorization endpoint. |
| `/token` | OIDC token endpoint. |
| `/userinfo` | OIDC UserInfo endpoint. |
| `/login`, `/logout` | Sign-in and sign-out interactions, always part of a flow. |
| `/enroll` | One-time authenticator enrollment. |
| `/panic` | The user emergency action. |
| `/admin`, `/admin/audit` | The admin interface and audit log. |
| `/health` | Liveness and readiness, returns `ok`. |
Most of these you never call by hand. Applications discover what they need from the discovery document, and the browser interactions take care of the rest. `health` is the one worth monitoring, and it is what the image's built-in health check polls.
## Behind a reverse proxy
Production terminates TLS in front and forwards to the plain HTTP listener. The proxy must forward the original scheme in `X-Forwarded-Proto`, everything else is ordinary proxying. With Caddy:
```text
auth.example.com {
reverse_proxy 127.0.0.1:8080
}
```
Caddy sends the header by default and manages certificates for you. With nginx:
```nginx
server {
listen 443 ssl;
server_name auth.example.com;
ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
Keep the listener bound to loopback, or unpublished inside a shared Docker network, so the plain HTTP surface is not reachable from outside. If you add edge protections such as IP rate limits or a WAF, that is the right layer for them, see [Security](/docs/security/).
## The state database
One SQLite file holds every session, outstanding enrollment token, rate-limit counter, lock, and audit record. Operationally it behaves like this:
- **Restarts and upgrades preserve it.** Sessions stay alive, unredeemed enrollment links stay valid, locks and counters stay enforced. Nothing to do, nothing to drain.
- **It is never the source of truth.** Users, clients, claims, and policy come from YAML, credentials come from the root secret. The file can be replaced without changing a single identity.
- **Deleting it is an operational reset.** Every session ends, outstanding enrollment links die, locks and counters clear, the audit log is gone. Everyone signs in again with codes that still work, because credentials are derived, not stored.
- **It contains live security state.** Restrict its permissions to the service user. It holds no secrets, but sessions and outstanding tokens are in it.
There is no maintenance job for the database itself. Background cleanup expires dead rows, rate-limit counters, spent tokens, ended sessions, and aged audit records, on a schedule you can tune through `config.maintenance`.
## Rotation procedures
Each rotatable value has a procedure sized to its blast radius:
**Rotate one user's TOTP credential.** Increment their `idp_totp_rev` in YAML, deploy, create a new enrollment token. Sessions authenticated at the old revision are revoked. Nothing and nobody else changes. This is the routine procedure for lost devices.
**Rotate the administrator password.** Run `generate-secrets`, replace `admin_password_hash` in YAML with the new hash, deploy. Current admin sessions end on their own terms. User authentication is untouched.
**Rotate a client secret.** Run `generate-secrets`, replace that client's `secret_hash`, deploy, then update the secret in the application. Doing the YAML side first means the application briefly fails auth until you paste the new value, so pick a quiet moment.
**Rotate the root secret.** Set the new `ZEN_IDP_SECRET`, restart, and treat it as a full credential event: the signing key changes, all users must re-enroll their authenticators, all sessions and outstanding enrollment links die with the change, and relying applications refresh their cached keys from JWKS on their own schedule. Announce it, schedule it, then run the enrollment campaign. See [Security](/docs/security/) for why it is shaped this way.
## Recovery scenarios
Three losses cover almost every bad day:
**Lost the state database.** The cheapest disaster. Start with the same configuration and root secret: identities, credentials, and the signing key are identical, because none of them came from the file. Users sign in again, you reissue enrollment links that were still outstanding, and locks plus the audit log start over clean.
**Lost the YAML configuration.** The service fails closed, as it must: without valid configuration there is nothing to authenticate. Restore from version control, which is the reason identity lives in files under review. Session rows in SQLite can never substitute for the configuration.
**Lost the root secret.** Not recoverable, by design. The signing identity and every TOTP credential are gone with it. The path forward is a new root secret and a full re-enrollment of every user, after which everything works again. This is why the secret lives in a secret manager, not in a notebook.
## Topology
Run **one active instance per issuer**. The state file is embedded SQLite on a local filesystem, and the guarantees around sessions, locks, one-use tokens, and rate limits are the guarantees of one process and one file.
Running two replicas against separate state files does not produce a redundant deployment, it produces two identity providers that disagree about who is signed in and who is locked. If you need more availability than one instance, the supported shapes are a fast restarting single container, or a cold standby with the state directory preserved, restarting onto the same data.
Do not place the state file on network shared storage. SQLite over NFS and friends trades correctness for the illusion of sharing, and the failure modes are the ugly kind of silent.
## Deployment checklist
For a production deployment, the short version of everything above:
1. Pin an exact image version and run `validate-config` against your configuration.
2. Put TLS in front, forward `X-Forwarded-Proto`, keep the listener private to the proxy.
3. Set an HTTPS issuer that matches the public URL users actually visit.
4. Keep the root secret in a secret manager, never in YAML or images.
5. Give the state directory to the service user and include it, or at least its host path, in your backup policy.
6. Monitor `/health` and alert on anything other than `ok`.
7. Test your recovery story once: replace the state file in a staging deployment and watch everyone sign in again with working codes.