IAM Server

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

GoVersion 1.26 or later. It builds the server.
NodeVersion 22 or later. It builds the web UI only.
DockerIt runs PostgreSQL, and it runs the server in production.
PostgreSQLVersion 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)
  1. Start Postgres

    This command starts a local PostgreSQL in Docker.

    terminal
    make compose-dev-up
  2. Generate a signing key

    This command writes an RSA key to ./signing.pem. The server signs id_tokens with this key.

    terminal
    make keygen
  3. 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
  4. 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
  5. 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
  6. 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.

terminal
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 v7 primary key. These keys sort by time.
  • Every timestamp is TIMESTAMPTZ in UTC.
  • A soft delete sets deleted_at; it does not remove the row.
  • Every migration has an up file and a down file.
  • Migration files use the name NNNN_description.up.sql and .down.sql.

Core tables

TablePurpose
usersThe canonical user record.
user_credentialsPasswords (argon2id hash) and passkeys.
user_mfa_methodsTOTP secrets and WebAuthn credentials.
sessionsBrowser sessions with a sliding expiry.
clientsRegistered OIDC/OAuth applications.
authorization_codesShort-lived OAuth authorization codes.
access_tokensIssued access tokens.
refresh_tokensIssued refresh tokens.
cas_servicesRegistered CAS service URLs.
upstream_providersFederated IdP configuration.
audit_logThe immutable, append-only event log.

Reset the database

To delete all local data, remove the Docker volume. Then start again from the development steps.

terminal
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 buildCompiles one binary with the web UI embedded into ./bin/iam-server.
make runBuilds, then runs the binary.
make dev-serverRuns the server with go run for development.

Test

The tests use a real Postgres through testcontainers-go. Docker must be running.

make testRuns all unit and integration tests with the race detector.
make test-coverRuns the tests and reports coverage.
make smoke-testRuns a black-box check against a running server (BASE_URL=http://localhost:8080).
terminal
# 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-devStarts the Vite dev server with hot reload on port 5173.
make web-buildBuilds the React admin UI into web/dist.
make web-lintChecks lint and format with Biome (no writes).
make web-formatFixes lint and format with Biome.

Keys & users

make keygenGenerates an RSA signing key at ./signing.pem (development).
make rotate-key dir=/etc/iam/keysGenerates a new signing key in a keys directory.
make seed-user email=… password=… admin=1Creates a user account.
make grant-admin email=…Promotes an existing user to admin.

Security scans

make vulnScans Go dependencies for known vulnerabilities (govulncheck).
make vuln-webScans 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.

required  you must set it secret  never log it or commit it Each row shows when to set the variable.

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:

  1. Create a branch. Use a prefix: feat/, fix/, chore/, or docs/.
  2. Write the code and a test for it.
  3. Run make test and make lint.
  4. Commit with a Conventional Commit message (for example, feat: add WebAuthn enrollment).
  5. Open a pull request.

Pull request rules

  • The CI must pass.
  • One reviewer must approve.
  • The code must have no lint warnings.

Git conventions

Branchesfeat/, fix/, chore/, docs/
CommitsConventional Commits (type: summary).
ReleasesA 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.

QuestionAction
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
  1. 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
  2. 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
  3. 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.

  4. 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
  5. 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.

  6. 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_URL matches what users type (scheme, host, no trailing slash).
  • <BASE_URL>/.well-known/openid-configuration returns JSON.
  • <BASE_URL>/healthz returns ok.
  • Your admin account has MFA enrolled.

Caddy example

Caddy is the documented companion proxy. It handles TLS for you.

Caddyfile
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.

Public no auth Session a signed-in user Client an OAuth2 client Admin an admin session or scope Spec a protocol validation endpoint

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-Token header.
  • A server-rendered form sends the token in the _csrf field.

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/forgot and POST /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/tokenA client authenticates with its credentials.
/oauth2/userinfoA bearer token authenticates the request.
/oauth2/introspectA client authenticates with its credentials.
/oauth2/revokeA client authenticates with its credentials.
/federation/The OAuth state parameter provides CSRF protection.
/proxy/verifyRead-only. It changes no state.
/metrics, /healthz, /readyzRead-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.

JavaScript
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

  1. An app sends the user to /cas/login?service=<app-url>.
  2. The server signs the user in. It issues a service ticket (ST).
  3. The server sends the user back to the app with the ticket.
  4. The app calls a validate endpoint to check the ticket.
  5. The server returns the user and, in CAS 3.0, the released attributes.

Endpoints

GET/cas/loginSigns in and issues a service ticket.
GET/cas/serviceValidateCAS 2.0 ticket validation.
GET/cas/p3/serviceValidateCAS 3.0 ticket validation, with attributes.
GET/cas/logoutEnds 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.

terminal
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.

terminal
curl https://auth.example.com/.well-known/openid-configuration
curl https://auth.example.com/.well-known/jwks.json

The Authorization Code flow with PKCE

  1. The app creates a code_verifier and a code_challenge.
  2. The app sends the user to /oauth2/authorize with the challenge.
  3. The user signs in and grants consent.
  4. The server returns an authorization code to the app.
  5. The app calls /oauth2/token with the code and the verifier.
  6. The server returns an id_token and an access_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.

terminal
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

terminal
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

MethodNotes
TOTPA 6-digit code from an authenticator app (RFC 6238). Enroll with a QR code.
WebAuthn / passkeyA FIDO2 security key or a platform passkey. Supports passwordless sign-in.
Backup codesTen single-use codes. Use one when you lose your other factor.

Enroll a factor

A signed-in user enrolls a factor at /mfa/enroll.

  1. Open /mfa/enroll.
  2. For TOTP, scan the QR code. Enter the 6-digit code to confirm.
  3. For a passkey, follow the browser prompt.
  4. 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_values to require MFA for its sign-in.
  • Global: set FORCE_MFA=true to 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:

  1. It looks for a user already linked to this provider and subject. If it finds one, it signs the user in.
  2. It looks for a user with the same email. If it finds one, it prompts the user to link the accounts.
  3. If it finds neither, it creates a new user and links the provider.

Set up Google

  1. Create an OAuth client in the Google Cloud console.
  2. Set the redirect URI to <BASE_URL>/oauth/callback/google.
  3. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.
  4. Restart the server. A Google button appears on the sign-in page.

Set up GitHub

  1. Create an OAuth app in GitHub settings.
  2. Set the callback URL to <BASE_URL>/oauth/callback/github.
  3. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.
  4. 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.

.env
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, and SameSite=Lax.
  • A https BASE_URL turns on the Secure flag automatically.

Response headers

The server sets these headers on every response:

Content-Security-PolicyRestricts sources to same-origin. It blocks external requests.
Strict-Transport-SecurityPins HTTPS for one year (only when BASE_URL is https).
X-Frame-Options: DENYBlocks embedding in an iframe (anti-clickjacking).
X-Content-Type-OptionsBlocks MIME-type sniffing.
Referrer-PolicySends the origin only across sites, not the full URL.
Permissions-PolicyDisables 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/healthzLiveness. Returns 200 whenever the process is up.
GET/readyzReadiness. Returns 200 only when the database is reachable.
GET/metricsPrometheus metrics.

Rotate the signing key

Rotate the key with no downtime. Old tokens stay valid until they expire.

  1. Generate a new key. Add it to the JWKS as a second key.
  2. Deploy. The server signs new tokens with the new key. It verifies old tokens against both keys.
  3. Wait for the maximum token lifetime to pass.
  4. Remove the old key from the JWKS.
terminal
make rotate-key dir=/etc/iam/keys

Update & logs

terminal
docker compose up -d --build   # update to a new build
docker compose logs -f iam     # follow the logs