Identity & Access Management
One binary for SSO, MFA, and OpenID Connect.
IAM Server gives your apps single sign-on, multi-factor authentication, and standards-based OpenID Connect. It adds CAS support and protects other apps behind a reverse proxy. You need only the binary and a PostgreSQL database.
OIDC & OAuth 2.0
Act as an authorization server for your apps, with PKCE, discovery, and JWKS.
CAS 2.0 / 3.0
Serve older apps that speak CAS. Bridge them to modern SSO providers.
MFA & passkeys
Enforce TOTP, WebAuthn/FIDO2, and single-use backup codes.
Reverse-proxy auth
Protect any app behind Caddy or Traefik with a forward-auth endpoint.
00 Introduction
What IAM Server is
IAM Server is a self-hosted identity server. It ships as one Go binary with the admin UI built in.
The server does five jobs:
- It acts as an OIDC / OAuth 2.0 provider for your apps.
- It acts as a CAS server for older apps.
- It lets users sign in with Google, GitHub, or any OIDC provider (Microsoft, Okta, GitLab, or Keycloak).
- It enforces MFA (authenticator apps and passkeys).
- It protects other apps behind Caddy or Traefik with forward-auth.
This page is a static reference. It describes the server in general. It does not read this instance's live configuration.
01 Installation
Install & run
Choose one path. Use local development to work on the code. Use production to run the server for real.
Requirements
| Go | Version 1.26 or later. It builds the server. |
| Node | Version 22 or later. It builds the web UI only. |
| Docker | It runs PostgreSQL, and it runs the server in production. |
| PostgreSQL | Version 16 or later. Docker provides it. |
Which path do I choose? To change the code and see it reload, follow Local development. To run the server on a host, follow Production deploy.
02 Local development
Run it locally
Postgres runs in Docker. The server runs on your machine. The make targets drive the whole loop.
Prerequisites
- Go 1.26 or later
- Node 22 or later (only to build the web UI)
- Docker (it provides PostgreSQL 16 or later)
-
Start Postgres
This command starts a local PostgreSQL in Docker.
terminal make compose-dev-up -
Generate a signing key
This command writes an RSA key to ./signing.pem. The server signs id_tokens with this key.
terminal make keygen -
Create a .env file in the project root
This is the minimum needed to start. SESSION_SECRET must be at least 32 bytes.
terminal cat > .env <<ENV DATABASE_URL=postgres://iam:iam@localhost:5432/iam?sslmode=disable BASE_URL=http://localhost:8080 SESSION_SECRET=$(openssl rand -hex 32) SIGNING_KEY_PATH=$PWD/signing.pem AUTO_MIGRATE=true LOG_LEVEL=debug DOCS_ENABLED=true ENV -
Load the .env and apply migrations
Load the .env into your shell. Then create the database schema.
terminal set -a; source .env; set +a make migrate-up -
Create your first admin user
This command seeds a local account. You sign in with this account.
terminal make seed-user [email protected] password=devpass123 admin=1 -
Run the backend and the web UI
Use two terminals. Port 8080 serves the embedded build. Port 5173 is the Vite dev server with hot reload. Both share the sign-in session. Use 5173 while you work on the UI. Use 8080 to test the real build. To reset all data, run make compose-dev-clear.
terminal make dev-server # Terminal A -> http://localhost:8080 make web-dev # Terminal B -> http://localhost:5173 # Or build the UI once, then run only the backend: make web-build make dev-server
Open http://localhost:8080 and sign in. Then set
DOCS_ENABLED=true to read this page at
/docs.
03 Database
Database & migrations
The server stores all state in PostgreSQL 16 or later. It applies schema changes with versioned migrations.
How migrations run
The migration files live in
internal/store/migrations. Each change has an
up file and a down file. The server
applies pending migrations on start when
AUTO_MIGRATE=true. To run them by hand, use the make
targets.
make migrate-up # apply all pending migrations
make migrate-down # roll back the most recent migration
make migrate-new name=add_foo # create a new up/down pair
The make targets read DATABASE_URL. Load your
.env first with set -a; source .env; set +a,
or the targets use the local default.
Schema conventions
- Every table uses a
UUID v7primary key. These keys sort by time. - Every timestamp is
TIMESTAMPTZin UTC. - A soft delete sets
deleted_at; it does not remove the row. - Every migration has an
upfile and adownfile. - Migration files use the name
NNNN_description.up.sqland.down.sql.
Core tables
| Table | Purpose |
|---|---|
users | The canonical user record. |
user_credentials | Passwords (argon2id hash) and passkeys. |
user_mfa_methods | TOTP secrets and WebAuthn credentials. |
sessions | Browser sessions with a sliding expiry. |
clients | Registered OIDC/OAuth applications. |
authorization_codes | Short-lived OAuth authorization codes. |
access_tokens | Issued access tokens. |
refresh_tokens | Issued refresh tokens. |
cas_services | Registered CAS service URLs. |
upstream_providers | Federated IdP configuration. |
audit_log | The immutable, append-only event log. |
Reset the database
To delete all local data, remove the Docker volume. Then start again from the development steps.
make compose-dev-clear # stop Postgres and delete the volume
04 Scripts & tests
Make targets & tests
The Makefile drives every task. Run
make help to list all targets.
Build & run
make build | Compiles one binary with the web UI embedded into ./bin/iam-server. |
make run | Builds, then runs the binary. |
make dev-server | Runs the server with go run for development. |
Test
The tests use a real Postgres through testcontainers-go.
Docker must be running.
make test | Runs all unit and integration tests with the race detector. |
make test-cover | Runs the tests and reports coverage. |
make smoke-test | Runs a black-box check against a running server (BASE_URL=http://localhost:8080). |
# run the full suite
make test
# smoke-test a running server on another port
BASE_URL=http://localhost:9000 make smoke-test
Web UI
make web-dev | Starts the Vite dev server with hot reload on port 5173. |
make web-build | Builds the React admin UI into web/dist. |
make web-lint | Checks lint and format with Biome (no writes). |
make web-format | Fixes lint and format with Biome. |
Keys & users
make keygen | Generates an RSA signing key at ./signing.pem (development). |
make rotate-key dir=/etc/iam/keys | Generates a new signing key in a keys directory. |
make seed-user email=… password=… admin=1 | Creates a user account. |
make grant-admin email=… | Promotes an existing user to admin. |
Security scans
make vuln | Scans Go dependencies for known vulnerabilities (govulncheck). |
make vuln-web | Scans the web UI's production dependencies (npm audit, high and up). |
05 Environment variables
Configuration
You configure the server with environment variables. An optional
YAML overlay (CONFIG_FILE) sets non-secret fields.
Secrets must come from the environment or mounted files, never
from committed YAML.
Core
The four variables every deployment needs, plus a few switches. Set BASE_URL and SESSION_SECRET in every environment.
BASE_URL
required
|
http://localhost:8080
|
The externally reachable URL of this server. It is used in OIDC discovery, CAS redirects, and email links. When to set: Always set it. In production, set it to the exact https URL that users type. A https value also turns on secure cookies and sets the passkey domain. |
SESSION_SECRET
required
secret
|
- | The HMAC key that signs session cookies. Use at least 32 bytes after hex decoding. When to set: Always set it. Generate it once per environment with openssl rand -hex 32. Do not reuse it across environments. If you change it, every session ends. |
ENV
|
dev
|
A free-form environment tag (dev, staging, or prod). It appears in logs and metrics. When to set: Set it in staging and production so logs are easy to tell apart. Leave it as dev on your machine. |
AUTO_MIGRATE
|
true
|
Applies any pending database migrations on start. When to set: Keep it true for a single-instance deployment. Set it false when a separate step runs migrations, or when you run more than one instance and want one controlled migration. |
DOCS_ENABLED
|
false
|
Serves this documentation page at /docs. When to set: Turn it on in development. Leave it off on a public production server unless you want the reference public. |
CONFIG_FILE
|
- | The path to an optional YAML overlay. The server applies it before the environment variables. It holds non-secret fields only. When to set: Set it only if you prefer a YAML file for non-secret config. Most deployments do not need it. Never put secrets in it. |
HTTP server
The listen address and the timeouts. The defaults are safe for most deployments.
HTTP_ADDR
|
:8080
|
The listen address. When to set: Change it only if port 8080 is taken, or you bind to one interface. |
HTTP_READ_TIMEOUT
|
30s
|
The request read timeout. When to set: Leave it at the default unless a proxy needs a different value. |
HTTP_WRITE_TIMEOUT
|
60s
|
The response write timeout. When to set: Leave it at the default unless a proxy needs a different value. |
HTTP_SHUTDOWN_TIMEOUT
|
30s
|
The graceful-shutdown grace period. When to set: Leave it at the default in most cases. |
Database
The Postgres connection and the pool sizing.
DATABASE_URL
required
secret
|
- | The PostgreSQL connection string (postgres://user:pass@host:5432/db). When to set: Always set it. Use sslmode=require against a remote database. Use sslmode=disable only for local Docker. |
DB_MAX_CONNS
|
20
|
The maximum number of pooled connections. When to set: Raise it under heavy load. Keep it below the Postgres max_connections limit. |
DB_MIN_CONNS
|
2
|
The minimum number of idle connections. When to set: Leave it at the default in most cases. |
DB_CONN_MAX_LIFETIME
|
1h
|
The maximum lifetime of a pooled connection. When to set: Lower it if a proxy or load balancer closes idle connections early. |
Logging
The log level and the format.
LOG_LEVEL
|
info
|
The log level (debug, info, warn, or error). When to set: Use debug in development. Use info or warn in production. |
LOG_FORMAT
|
text
|
The log format (json or text). When to set: Use text for a readable terminal. Use json when a log collector parses the output. |
Crypto & tokens
The key that signs OIDC id_tokens.
SIGNING_KEY_PATH
|
- | The path to a PEM-encoded RSA private key. The server signs id_tokens with this key. If it is unset, the server generates a temporary key at start, and a restart invalidates all tokens. When to set: Set it in every environment where tokens must survive a restart. This means always, in practice. Leave it unset only for a throwaway test. |
Sessions & proxy trust
The cookie scope, and which proxies the server trusts for the client IP.
SESSION_COOKIE_DOMAIN
|
- | Overrides the Domain attribute of the session cookie. Start the value with a dot to cover all subdomains (for example, .example.com). When to set: Set it only for the reverse-proxy companion, so one cookie works across subdomains. Leave it unset for a single host. A wrong value silently breaks sessions. |
TRUSTED_PROXIES
|
- | A CIDR allow-list of proxy IP ranges. The server trusts X-Forwarded-For from these ranges only. When to set: Set it when a proxy or load balancer sits in front. Leave it unset when the server faces the internet directly, so no client can spoof its IP. |
MFA
The labels shown in authenticator apps, and the global MFA switch.
MFA_ISSUER
|
- | The TOTP issuer label shown in authenticator apps. When to set: Set it to your product or company name so users recognize the entry. |
MFA_WEBAUTHN_RP_NAME
|
- | The WebAuthn relying-party display name. When to set: Set it to your product or company name so the passkey prompt is clear. |
FORCE_MFA
|
false
|
Requires MFA for all users. When to set: Turn it on when every user must use a second factor. Leave it off to allow per-user or per-client policy. |
Outbound email (SMTP)
The mail server for verification and password-reset emails. When SMTP_HOST is empty, the server logs emails instead of sending them.
SMTP_HOST
|
- | The SMTP server host. When it is empty, the server logs emails instead of sending them (development only). When to set: Set it in production, and whenever you test signup or password reset. Leave it empty in development to read the email in the logs. |
SMTP_PORT
|
- | The SMTP server port (for example, 587). When to set: Set it with SMTP_HOST. |
SMTP_USERNAME
|
- | The SMTP auth username. When to set: Set it when the mail server needs authentication. |
SMTP_PASSWORD
secret
|
- | The SMTP auth password. When to set: Set it when the mail server needs authentication. |
SMTP_FROM
|
- | The From address for outbound mail. When to set: Set it with SMTP_HOST. Use an address your mail server is allowed to send from. |
Self-service signup
The optional public registration flow at /signup.
SIGNUP_ENABLED
|
false
|
Opens public registration at /signup. When to set: Turn it on for an open service. Leave it off for an internal server where an admin creates users. |
SIGNUP_MIN_PASSWORD_LENGTH
|
12
|
The minimum password length for self-signup. When to set: Raise it for a stronger policy. Do not lower it below 12. |
SIGNUP_TOKEN_TTL_HOURS
|
24
|
The lifetime of email-verification links, in hours. When to set: Lower it for a stricter window. Raise it if users report expired links. |
Reverse proxy
Controls the redirect-back behaviour of the forward-auth endpoint.
PROXY_ALLOWED_CALLBACK_ORIGINS
|
- | An allow-list of cross-origin destinations that /proxy/verify may redirect back to after sign-in. When to set: Set it when the reverse-proxy companion protects apps on other subdomains. Leave it unset if you do not use forward-auth. An unlisted origin gets a bare 401 with no Location header. |
Federation (upstream SSO)
Credentials for upstream providers. A provider becomes active only when both its id and secret are set.
GOOGLE_CLIENT_ID
|
- | The Google OAuth client id. When to set: Set both the id and the secret to add a Google sign-in button. Leave both unset to hide it. |
GOOGLE_CLIENT_SECRET
secret
|
- | The Google OAuth client secret. When to set: Set it with GOOGLE_CLIENT_ID. |
GITHUB_CLIENT_ID
|
- | The GitHub OAuth client id. When to set: Set both the id and the secret to add a GitHub sign-in button. Leave both unset to hide it. |
GITHUB_CLIENT_SECRET
secret
|
- | The GitHub OAuth client secret. When to set: Set it with GITHUB_CLIENT_ID. |
OIDC_PROVIDERS
|
- | A comma-separated list of slugs that enable generic OIDC upstreams (for example, okta,keycloak,auth0). When to set: Set it to add any OIDC provider that is not built in. For each slug, add the four OIDC_<SLUG>_* variables below. |
OIDC_<SLUG>_ISSUER
|
- | The issuer URL for one provider. Repeat the block for each slug in OIDC_PROVIDERS. When to set: Set it for each slug you listed in OIDC_PROVIDERS. |
OIDC_<SLUG>_CLIENT_ID
|
- | The client id for one provider. When to set: Set it for each slug you listed in OIDC_PROVIDERS. |
OIDC_<SLUG>_CLIENT_SECRET
secret
|
- | The client secret for one provider. When to set: Set it for each slug you listed in OIDC_PROVIDERS. |
OIDC_<SLUG>_DISPLAY_NAME
|
- | The button label for one provider (optional). When to set: Set it to control the button text. Leave it unset to use the slug. |
OIDC_<SLUG>_SCOPES
|
- | The space-separated scopes for one provider (optional). When to set: Set it to request more scopes. Leave it unset to use the default openid profile email. |
Docker bootstrap (entrypoint only)
These variables are read by the Docker entrypoint, not by the server. They seed the first admin on the first run.
ADMIN_EMAIL
|
- | The email of the first admin account. The entrypoint creates this account on the first start. When to set: Set it once for the first Docker run. It has no effect after the account exists. |
ADMIN_PASSWORD
secret
|
- | The password of the first admin account. When to set: Set it once for the first Docker run. Change the password after you sign in. |
ADMIN_NAME
|
- | The display name of the first admin account. When to set: Set it once for the first Docker run (optional). |
ADMIN_USERNAME
|
- | The username of the first admin account. When to set: Set it once for the first Docker run (optional). |
No variable matches your filter.
06 Contribution
Contribution rules
Follow these rules for every change. They keep the codebase small, safe, and consistent.
The development loop
Set up your machine with the Local development steps. Then use this loop for each change:
- Create a branch. Use a prefix:
feat/,fix/,chore/, ordocs/. - Write the code and a test for it.
- Run
make testandmake lint. - Commit with a Conventional Commit message (for example,
feat: add WebAuthn enrollment). - Open a pull request.
Pull request rules
- The CI must pass.
- One reviewer must approve.
- The code must have no lint warnings.
Git conventions
| Branches | feat/, fix/, chore/, docs/ |
| Commits | Conventional Commits (type: summary). |
| Releases | A semver tag (v1.2.3) triggers the release build. |
Add a dependency
Keep the runtime section of go.mod short. Before you
add a dependency, answer these questions. If a pull request adds a
require entry, justify it in the commit message.
| Question | Action |
|---|---|
| Is it a protocol or spec implementation? (WebAuthn, JWT, TOTP) | Keep it. Specs have sharp edges and a subtle attack surface. |
| Is it glue or boilerplate? (routing, config, logging) | Replace it with a stdlib equivalent. |
| Is it a dev-time code generator? (sqlc, migrate) | Keep it. It has zero runtime cost and prevents bugs. |
| Would it take under 200 lines to write? (JWKS serialisation) | Write it in internal/crypto. |
Is it maintained by the Go team? (golang.org/x/*) | Treat it as an extended stdlib. |
Code style
- The dependency flow is
api → auth → store. It never runs the other way. internal/packages are private.pkg/packages are public.- Define sentinel errors in each package. Wrap errors with context.
- HTTP handlers return RFC 7807 Problem JSON on error.
- Put unit tests next to the source file (
foo_test.go).
07 Production deploy
Deploy to production
Everything runs in Docker on your server: PostgreSQL and the single IAM binary. Terminate TLS in front of the server.
Prerequisites
- Docker on the host
- A domain and TLS in front (a reverse proxy or a tunnel)
- A backup plan for two things: the pgdata volume and signing/signing.pem
-
Configure the environment
Copy the compose env template. Fill in real values: BASE_URL (your real https URL), POSTGRES_PASSWORD, SESSION_SECRET, and ADMIN_EMAIL with ADMIN_PASSWORD (your first admin sign-in).
terminal cd deployments cp .env.example .env $EDITOR .env # POSTGRES_PASSWORD: openssl rand -hex 24 # SESSION_SECRET: openssl rand -hex 32 -
Create and protect the signing key
This key signs every token. Set the owner to the container's non-root user. Restrict the file mode. Back up the key off-host. If you lose the key, every issued token stops working.
terminal mkdir -p signing openssl genrsa -out signing/signing.pem 2048 sudo chown 65532:65532 signing/signing.pem chmod 600 signing/signing.pem -
Put the server behind TLS
The server speaks HTTP. Terminate HTTPS in front. Use a reverse proxy (Caddy is the documented companion; Traefik also works) or a tunnel (for example, Cloudflare) that points at the container's HTTP port. Set BASE_URL to the public https URL. BASE_URL must match what the proxy serves.
-
Start the stack
This command starts Postgres and IAM. The server creates the first admin account from ADMIN_EMAIL and ADMIN_PASSWORD on the first run.
terminal docker compose up -d --build -
Harden the deployment
Sign in. Then enroll a second factor at /mfa/enroll. Set SIGNUP_ENABLED=false to keep registration closed. Set SESSION_COOKIE_DOMAIN and PROXY_ALLOWED_CALLBACK_ORIGINS if you use the reverse-proxy companion across subdomains.
-
Operate the server
Update the server by a rebuild. Follow the logs with the compose logs command.
terminal docker compose up -d --build # update docker compose logs -f iam # logs
Back up two things
The pgdata volume holds your whole database.
The signing/signing.pem file signs your tokens.
If you lose the signing key, every issued token stops working. Back
up both, off-host.
Check before you go live
BASE_URLmatches what users type (scheme, host, no trailing slash).<BASE_URL>/.well-known/openid-configurationreturns JSON.<BASE_URL>/healthzreturnsok.- Your admin account has MFA enrolled.
Caddy example
Caddy is the documented companion proxy. It handles TLS for you.
app.example.com {
forward_auth iam:8080 {
uri /proxy/verify
copy_headers X-Auth-User X-Auth-Email X-Auth-Groups
}
reverse_proxy backend:3000
}
08 API routes
HTTP API reference
Every route the server exposes, grouped by area. Use the filter in the sidebar to find a route fast.
Health & discovery
Unauthenticated endpoints for health checks and OIDC metadata.
| GET | /healthz |
Public | Liveness. Returns 200 whenever the process is up. |
| GET | /readyz |
Public | Readiness. Returns 200 only when the database is reachable. |
| GET | /metrics |
Public | Prometheus metrics. |
| GET | /.well-known/openid-configuration |
Public | OIDC discovery document. |
| GET | /.well-known/jwks.json |
Public | JSON Web Key Set: the public keys that verify tokens. |
OAuth 2.0 / OpenID Connect provider
The authorization-server surface. Supported flows: Authorization Code with PKCE, Client Credentials, and Refresh Token.
| GET | /oauth2/authorize |
Session | Authorization endpoint. Starts the code flow. Requires a user session. |
| POST | /oauth2/authorize |
Session | Consent submission for the authorization endpoint. |
| POST | /oauth2/token |
Client | Token endpoint: code exchange, refresh, and client credentials. |
| GET | /oauth2/userinfo |
Client | UserInfo endpoint (bearer token). |
| POST | /oauth2/userinfo |
Client | UserInfo endpoint (bearer token). |
| POST | /oauth2/introspect |
Client | Token introspection (RFC 7662). |
| POST | /oauth2/revoke |
Client | Token revocation (RFC 7009). |
CAS
CAS 2.0 and 3.0 protocol endpoints. The validation responses are XML.
| GET | /cas/login |
Public | CAS login. Authenticates the user. Issues a service ticket for the requested service. |
| POST | /cas/login |
Public | Credential submission for CAS login. |
| GET | /cas/logout |
Session | Ends the session (single logout). |
| GET | /cas/validate |
Spec | CAS 1.0 ticket validation (plain text). |
| GET | /cas/serviceValidate |
Spec | CAS 2.0 ticket validation. |
| GET | /cas/p3/serviceValidate |
Spec | CAS 3.0 ticket validation (adds released attributes). |
| GET | /cas/proxyValidate |
Spec | Proxy-ticket validation. |
| GET | /cas/p3/proxyValidate |
Spec | CAS 3.0 proxy-ticket validation. |
Reverse-proxy companion
The forward-auth endpoint for Caddy (primary) and Traefik.
| GET | /proxy/verify |
Session | Returns 200 with X-Auth-User, X-Auth-Email, and X-Auth-Groups on success. Returns 401 with a Location header to login on failure. Accepts a session cookie or a bearer token. |
Upstream SSO (federation)
Sign in through an external identity provider (Google, GitHub, Microsoft, GitLab, or generic OIDC/OAuth2).
| GET | /oauth/authorize/{provider} |
Public | Starts sign-in with an upstream provider. |
| GET | /oauth/callback/{provider} |
Public | The upstream callback. Links to or creates the local user. |
Self-service
End-user account flows. Registration stays closed unless you set SIGNUP_ENABLED.
| GET | /signup |
Public | Registration form. |
| POST | /signup |
Public | Creates an account. Sends a verification email. |
| GET | /verify |
Public | Email-verification link handler. |
| GET | /password/forgot |
Public | Forgot-password form. |
| POST | /password/forgot |
Public | Requests a password-reset email. |
| GET | /password/reset |
Public | Password-reset form (the token is in the query). |
| POST | /password/reset |
Public | Sets a new password with a valid reset token. |
MFA (challenge & enrollment)
The second-factor challenge during sign-in, and enrollment for the signed-in user. Supports TOTP, WebAuthn/passkeys, and backup codes.
| GET | /mfa |
Session | MFA challenge page (a pending-MFA session). |
| POST | /mfa/totp |
Session | Submits a TOTP code. |
| POST | /mfa/webauthn/begin |
Session | Starts a WebAuthn assertion. |
| POST | /mfa/webauthn/finish |
Session | Completes a WebAuthn assertion. |
| GET | /mfa/backup |
Session | Backup-code challenge page. |
| POST | /mfa/backup |
Session | Submits a backup code. |
| GET | /mfa/enroll |
Session | Enrollment landing page. |
| GET | /mfa/enroll/totp |
Session | Starts TOTP enrollment (a QR code and a secret). |
| POST | /mfa/enroll/totp/confirm |
Session | Confirms TOTP enrollment. |
| POST | /mfa/enroll/webauthn/begin |
Session | Starts WebAuthn enrollment. |
| POST | /mfa/enroll/webauthn/finish |
Session | Completes WebAuthn enrollment. |
| POST | /mfa/enroll/backup |
Session | Generates a fresh set of backup codes. |
| POST | /mfa/methods/{id}/delete |
Session | Removes an enrolled method. |
Admin API
The management REST API under /admin/v1. It requires an admin session or a machine-to-machine client with the admin scope. The React admin UI is served at /admin.
| GET | /admin |
Admin | Admin single-page application. |
| GET | /admin/v1/me |
Admin | The current admin identity. |
| GET | /admin/v1/users |
Admin | Lists users. |
| POST | /admin/v1/users |
Admin | Creates a user. |
| GET | /admin/v1/users/{id} |
Admin | Gets a user. |
| PATCH | /admin/v1/users/{id} |
Admin | Updates a user. |
| DELETE | /admin/v1/users/{id} |
Admin | Soft-deletes a user. |
| POST | /admin/v1/users/{id}/lock |
Admin | Locks a user. |
| POST | /admin/v1/users/{id}/unlock |
Admin | Unlocks a user. |
| POST | /admin/v1/users/{id}/password |
Admin | Sets or force-resets a user's password. |
| POST | /admin/v1/users/{id}/revoke-all |
Admin | Revokes all of a user's sessions. |
| GET | /admin/v1/users/{id}/sessions |
Admin | Lists a user's sessions. |
| GET | /admin/v1/users/{id}/mfa |
Admin | Lists a user's MFA methods. |
| DELETE | /admin/v1/users/{id}/mfa |
Admin | Removes all of a user's MFA methods. |
| DELETE | /admin/v1/users/{id}/mfa/{methodId} |
Admin | Removes one MFA method. |
| GET | /admin/v1/users/{id}/federation |
Admin | Lists a user's federated identities. |
| DELETE | /admin/v1/users/{id}/federation/{linkId} |
Admin | Unlinks a federated identity. |
| GET | /admin/v1/clients |
Admin | Lists OIDC/OAuth2 clients. |
| POST | /admin/v1/clients |
Admin | Registers a client. |
| GET | /admin/v1/clients/{id} |
Admin | Gets a client. |
| PATCH | /admin/v1/clients/{id} |
Admin | Updates a client (this includes require_allowlist). |
| DELETE | /admin/v1/clients/{id} |
Admin | Deletes a client. |
| POST | /admin/v1/clients/{id}/rotate |
Admin | Rotates a client secret. |
| GET | /admin/v1/clients/{id}/allowlist |
Admin | Lists a client's email allow-list. |
| POST | /admin/v1/clients/{id}/allowlist |
Admin | Adds emails to a client's allow-list. |
| DELETE | /admin/v1/clients/{id}/allowlist |
Admin | Removes emails from a client's allow-list. |
| GET | /admin/v1/cas-services |
Admin | Lists CAS services. |
| POST | /admin/v1/cas-services |
Admin | Registers a CAS service. |
| GET | /admin/v1/cas-services/{id} |
Admin | Gets a CAS service. |
| PATCH | /admin/v1/cas-services/{id} |
Admin | Updates a CAS service (this includes require_allowlist). |
| DELETE | /admin/v1/cas-services/{id} |
Admin | Deletes a CAS service. |
| GET | /admin/v1/cas-services/{id}/allowlist |
Admin | Lists a CAS service's email allow-list. |
| POST | /admin/v1/cas-services/{id}/allowlist |
Admin | Adds emails to a CAS service's allow-list. |
| DELETE | /admin/v1/cas-services/{id}/allowlist |
Admin | Removes emails from a CAS service's allow-list. |
| GET | /admin/v1/federation/providers |
Admin | Lists the configured upstream providers. |
| GET | /admin/v1/audit |
Admin | Searches the audit log. |
| GET | /admin/v1/keys |
Admin | Views the active signing keys (JWKS). |
Documentation
This page.
| GET | /docs |
Public | This reference. The server serves it only when DOCS_ENABLED is set. |
No route matches your filter.
09 CSRF
CSRF protection
CSRF is cross-site request forgery. An attacker tricks a browser into sending a request with the user's session cookie attached. The server blocks this with a double-submit cookie.
How it works
The server issues a random token in a cookie named
iam_csrf. The cookie is readable by JavaScript, so the
admin UI can echo it. On every state-changing request, the client
must present the same token. The server compares the two values.
- The admin UI sends the token in the
X-CSRF-Tokenheader. - A server-rendered form sends the token in the
_csrffield.
The server checks POST, PUT, PATCH, and DELETE requests. It does not check GET, HEAD, or OPTIONS, because those requests are safe.
Which routes are checked
The server protects the browser flows:
POST /cas/login/password/forgotandPOST /password/reset- The
/mfa/*POST routes (verify and enroll) - The
/admin/v1/*POST, PATCH, and DELETE routes
Which routes are exempt
Machine-to-machine and read-only routes skip CSRF. A browser does not drive them, so a forged request is not a risk.
/oauth2/token | A client authenticates with its credentials. |
/oauth2/userinfo | A bearer token authenticates the request. |
/oauth2/introspect | A client authenticates with its credentials. |
/oauth2/revoke | A client authenticates with its credentials. |
/federation/ | The OAuth state parameter provides CSRF protection. |
/proxy/verify | Read-only. It changes no state. |
/metrics, /healthz, /readyz | Read-only operations endpoints. |
When you add a new state-changing route, review the exempt list. A route added without review can silently weaken the protection.
Send the token from a client
Read the iam_csrf cookie. Send its value in the
X-CSRF-Token header.
const token = document.cookie
.split("; ")
.find((c) => c.startsWith("iam_csrf="))
?.split("=")[1];
await fetch("/admin/v1/users", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": token },
body: JSON.stringify({ email: "[email protected]" }),
});
10 CAS
CAS
CAS is the Central Authentication Service protocol. The server accepts CAS 2.0 and 3.0. Older apps use it for single sign-on.
The sign-in flow
- An app sends the user to
/cas/login?service=<app-url>. - The server signs the user in. It issues a service ticket (ST).
- The server sends the user back to the app with the ticket.
- The app calls a validate endpoint to check the ticket.
- The server returns the user and, in CAS 3.0, the released attributes.
Endpoints
| GET | /cas/login | Signs in and issues a service ticket. |
| GET | /cas/serviceValidate | CAS 2.0 ticket validation. |
| GET | /cas/p3/serviceValidate | CAS 3.0 ticket validation, with attributes. |
| GET | /cas/logout | Ends the session (single logout). |
The CAS-to-SSO bridge
A CAS app can use a modern provider. When the user has no local session, the server starts an upstream OIDC or OAuth flow (Google, GitHub, and others). It maps the returned identity to a local user. Then it issues the CAS ticket.
Client App --CAS login--> IAM Server --OIDC flow--> Google / GitHub
<-- ST -- <-- id_token --
Client App --validate ST--> IAM Server
<-- user attrs --
Register a CAS service
The server issues a ticket only for a registered service URL. Add the service in the admin UI, or with the admin API.
curl -X POST https://auth.example.com/admin/v1/cas-services \
-H "X-CSRF-Token: $TOKEN" \
-H "Content-Type: application/json" \
--cookie "iam_session=$SESSION" \
-d '{"name":"Wiki","service_url":"https://wiki.example.com/.*"}'
To limit a service to some users, turn on
require_allowlist for that service. Then add the
allowed emails. The server checks the list at ticket issuance.
11 OpenID Connect
OpenID Connect & OAuth 2.0
The server is a standards-conformant authorization server. Modern apps use it for sign-in and for API authorization.
Supported flows
- Authorization Code with PKCE: for web apps, single-page apps, and mobile apps.
- Client Credentials: for machine-to-machine access.
- Refresh Token: to get a new access token without a new sign-in.
The Implicit flow and the Password flow are off by default.
Discovery
An app reads the server's metadata from the discovery document. It finds every endpoint and the signing keys there.
curl https://auth.example.com/.well-known/openid-configuration
curl https://auth.example.com/.well-known/jwks.json
The Authorization Code flow with PKCE
- The app creates a
code_verifierand acode_challenge. - The app sends the user to
/oauth2/authorizewith the challenge. - The user signs in and grants consent.
- The server returns an authorization code to the app.
- The app calls
/oauth2/tokenwith the code and the verifier. - The server returns an
id_tokenand anaccess_token.
PKCE is mandatory for public clients. The server accepts the
S256 method only.
Register a client
Add a client in the admin UI, or with the admin API. Set the redirect URIs and the scopes. A public client (a single-page app or a mobile app) uses PKCE and holds no secret.
curl -X POST https://auth.example.com/admin/v1/clients \
-H "X-CSRF-Token: $TOKEN" \
-H "Content-Type: application/json" \
--cookie "iam_session=$SESSION" \
-d '{
"name": "My SPA",
"redirect_uris": ["https://app.example.com/callback"],
"is_public": true,
"require_pkce": true
}'
Exchange a code for tokens
curl -X POST https://auth.example.com/oauth2/token \
-d grant_type=authorization_code \
-d code=$CODE \
-d redirect_uri=https://app.example.com/callback \
-d client_id=my-spa \
-d code_verifier=$VERIFIER
12 MFA
Multi-factor authentication
MFA adds a second factor to sign-in. The server challenges the user after the password step.
Methods
| Method | Notes |
|---|---|
| TOTP | A 6-digit code from an authenticator app (RFC 6238). Enroll with a QR code. |
| WebAuthn / passkey | A FIDO2 security key or a platform passkey. Supports passwordless sign-in. |
| Backup codes | Ten single-use codes. Use one when you lose your other factor. |
Enroll a factor
A signed-in user enrolls a factor at /mfa/enroll.
- Open
/mfa/enroll. - For TOTP, scan the QR code. Enter the 6-digit code to confirm.
- For a passkey, follow the browser prompt.
- Generate backup codes. Store them in a safe place.
Enforce MFA
You control MFA at two levels:
- Per user: a user enrolls a factor. The server then challenges that user.
- Per client: a client sends
acr_valuesto require MFA for its sign-in. - Global: set
FORCE_MFA=trueto require MFA for everyone.
Enroll a second factor on your admin account right after the first sign-in. Generate backup codes at the same time.
13 Federation
Set up federation (upstream SSO)
Federation lets users sign in with an external provider. The server maps the external identity to a local user.
How linking works
On the provider callback, the server does three checks in order:
- It looks for a user already linked to this provider and subject. If it finds one, it signs the user in.
- It looks for a user with the same email. If it finds one, it prompts the user to link the accounts.
- If it finds neither, it creates a new user and links the provider.
Set up Google
- Create an OAuth client in the Google Cloud console.
- Set the redirect URI to
<BASE_URL>/oauth/callback/google. - Set
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET. - Restart the server. A Google button appears on the sign-in page.
Set up GitHub
- Create an OAuth app in GitHub settings.
- Set the callback URL to
<BASE_URL>/oauth/callback/github. - Set
GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRET. - Restart the server. A GitHub button appears on the sign-in page.
Set up any OIDC provider
Use the generic OIDC connector for Microsoft, Okta, GitLab, Keycloak, and others. List each provider by a slug. Then add its four variables.
OIDC_PROVIDERS=microsoft,okta
OIDC_MICROSOFT_ISSUER=https://login.microsoftonline.com/common/v2.0
OIDC_MICROSOFT_CLIENT_ID=...
OIDC_MICROSOFT_CLIENT_SECRET=...
OIDC_MICROSOFT_DISPLAY_NAME=Microsoft
OIDC_OKTA_ISSUER=https://acme.okta.com
OIDC_OKTA_CLIENT_ID=...
OIDC_OKTA_CLIENT_SECRET=...
Set the redirect URI at the provider to
<BASE_URL>/oauth/callback/<slug>. For the
example above, the slugs are microsoft and
okta.
In Docker Compose, the variables must reach the container. Add
each variable to the iam.environment block, or the
provider stays inactive.
14 Systems
Systems & features
What each part of the server does.
CAS gateway
The entry-point SSO protocol. It accepts CAS 2.0 and 3.0 authentication. It fulfils the request from the local IdP or an upstream provider.
- CAS 2.0 and 3.0 login, logout, and ticket validation
- CAS 3.0 attribute release, configured per service
- Proxy-ticket validation
- A bridge to upstream OIDC/OAuth sign-in when the user is not authenticated locally
OpenID Connect / OAuth 2.0 provider
A standards-conformant authorization server.
- Authorization Code with PKCE, Client Credentials, and Refresh Token flows
- A discovery document and a JWKS endpoint
- Token introspection (RFC 7662) and revocation (RFC 7009)
- A UserInfo endpoint; acr and amr are carried from the authenticating session
Upstream SSO federation
It delegates authentication to external identity providers. It maps the result to a local user.
- Built-in Google and GitHub; config-driven generic OIDC and OAuth2
- Account linking: match by (provider, sub), then by email, or else create
- Caches provider claims for later attribute release
Multi-factor authentication
A second-factor challenge on sign-in, and self-service enrollment.
- TOTP (RFC 6238) with QR-code enrollment
- WebAuthn / FIDO2 passkeys
- Single-use backup codes
- Per-user and per-client (acr_values) enforcement; optional FORCE_MFA
Per-service email allow-list
It restricts one OIDC client or CAS service to an explicit set of user emails.
- Opt in per service with require_allowlist; open by default
- OIDC is enforced at the authorization-code step; CAS is enforced at ticket issuance
- A per-service access gate; a denial never locks the account
- Managed from the admin API and UI
Reverse-proxy companion
A forward-auth endpoint. It lets any compatible proxy delegate authentication to the IAM server.
- First-class Caddy (forward_auth) support; Traefik (ForwardAuth) is supported
- Returns identity headers (X-Auth-User, X-Auth-Email, and X-Auth-Groups) on success
- Accepts a session cookie or a bearer token
Admin portal
A REST API and a React UI to operate the server.
- Manage users, clients, CAS services, allow-lists, and upstream providers
- Rotate client secrets; view and trigger signing-key rotation
- Search and export the audit log
Sessions & key management
The browser-session lifecycle, and the JWKS that signs tokens.
- HMAC-signed session cookies with sliding expiry; the session doubles as the CAS ticket-granting ticket
- JWKS with zero-downtime key rotation: sign with the new key, verify against both, then retire the old key
Audit logging
An append-only record of every security-relevant event.
- An immutable log with actor, target, IP, user-agent, and JSON metadata
- Covers sign-in, MFA, the token lifecycle, admin actions, allow-list changes, and access denials
Self-service flows
End-user account management that works without an admin.
- Optional public signup with email verification
- Forgot and reset password with single-use, time-limited tokens
15 Security
Security defaults
The server ships with safe defaults. This section lists them so you know what protects your deployment.
Passwords & sessions
- The server hashes passwords with Argon2id.
- Session cookies are HMAC-SHA256 signed. They set
HttpOnly,Secure, andSameSite=Lax. - A https
BASE_URLturns on theSecureflag automatically.
Response headers
The server sets these headers on every response:
Content-Security-Policy | Restricts sources to same-origin. It blocks external requests. |
Strict-Transport-Security | Pins HTTPS for one year (only when BASE_URL is https). |
X-Frame-Options: DENY | Blocks embedding in an iframe (anti-clickjacking). |
X-Content-Type-Options | Blocks MIME-type sniffing. |
Referrer-Policy | Sends the origin only across sites, not the full URL. |
Permissions-Policy | Disables APIs the server does not use. |
Rate limits
- The login endpoint accepts 5 requests per minute per IP.
- The token endpoint accepts 20 requests per minute per client.
Report a vulnerability
Do not open a public issue for a security problem. Report it
privately. See SECURITY.md in the repository.
16 Operations
Operations
Run and maintain the server after it is live.
Health endpoints
| GET | /healthz | Liveness. Returns 200 whenever the process is up. |
| GET | /readyz | Readiness. Returns 200 only when the database is reachable. |
| GET | /metrics | Prometheus metrics. |
Rotate the signing key
Rotate the key with no downtime. Old tokens stay valid until they expire.
- Generate a new key. Add it to the JWKS as a second key.
- Deploy. The server signs new tokens with the new key. It verifies old tokens against both keys.
- Wait for the maximum token lifetime to pass.
- Remove the old key from the JWKS.
make rotate-key dir=/etc/iam/keys
Update & logs
docker compose up -d --build # update to a new build
docker compose logs -f iam # follow the logs