A minimal, personal OIDC provider written in Rust. Secures personal web applications with standard OpenID Connect authentication. Designed for a single operator managing a small set of users and applications.
Keycloak is too much. Auth0 costs money. This is a single binary with a SQLite database that implements the OIDC spec well enough to put behind a reverse proxy and protect a handful of personal apps.
- Multi-realm — isolated identity domains (users, clients, tokens)
- OIDC authorization code flow with PKCE
- Multi-algorithm signing — RS256 (default, required by Kubernetes) or EdDSA, per-realm, with concurrent keys during rotation
- Argon2id password hashing
- TOTP multi-factor auth — per-realm enforced or per-user opt-in; recovery codes; in-line enrollment during login
- Refresh token rotation with RFC 7009 revocation
- Confidential clients — optional client_secret for server-side apps (Forgejo, etc.)
- Groups claim —
groupsarray in ID tokens and UserInfo for RBAC (Kubernetes, etc.) - Rate limiting — per-IP login + per-user MFA attempt throttling
- Audit logging — JSON-line event log for login, token, and session activity
- Per-realm branding — customizable login page (colors, logo, CSS)
- Minimal login UI — server-rendered HTML, no JavaScript frameworks
- CLI admin — no admin web UI, just
anz realm/user/client/sessioncommands - SQLite — single file, embedded, no external database
cp anz.toml.example anz.toml
# edit anz.toml with your issuer URL
anz realm create myapp
anz user add --realm myapp --username alice --email alice@example.com
anz client add --realm myapp --client-id myapp-web --redirect-uri http://localhost:3000/callback
anz serveSee anz.toml.example:
bind_address = "127.0.0.1:8080"
issuer_base_url = "https://auth.example.com"
database_path = "anz.db"Deploy behind a TLS-terminating reverse proxy (nginx, caddy, etc.). Set issuer_base_url to your public HTTPS URL — Kubernetes and other OIDC consumers require HTTPS and will reject tokens from HTTP issuers.
Confidential clients authenticate via client_secret_post (secret in the POST body) or client_secret_basic (HTTP Basic auth header).
All endpoints are realm-scoped:
| Endpoint | Path |
|---|---|
| Discovery | GET /realms/{realm}/.well-known/openid-configuration |
| JWKS | GET /realms/{realm}/jwks |
| Authorize | GET /realms/{realm}/authorize |
| Token | POST /realms/{realm}/token |
| UserInfo | GET /realms/{realm}/userinfo |
| Password | POST /realms/{realm}/password |
| Revoke | POST /realms/{realm}/revoke |
anz realm create <name> [--key-type rs256|ed25519] # defaults to rs256
anz realm list
anz realm delete <name>
anz realm rotate-key --realm <r> --key-type rs256|ed25519
anz realm deactivate-key --realm <r> --kid <kid> # stops signing; kid still in JWKS
anz realm delete-key --realm <r> --kid <kid> # removes kid from JWKS entirely
anz realm set-mfa-required --realm <r> # force MFA for all users
anz realm set-mfa-required --realm <r> --required false
anz user add --realm <r> --username <u> --email <e> [--groups admin,dev]
anz user update-groups --realm <r> --username <u> --groups <g1,g2>
anz user enroll-mfa --realm <r> --username <u> # prints QR + recovery codes
anz user disable-mfa --realm <r> --username <u> # operator escape hatch
anz user list --realm <r>
anz user remove --realm <r> --username <u>
anz client add --realm <r> --client-id <id> --redirect-uri <uri> [--secret]
anz client list --realm <r>
anz client remove --realm <r> --client-id <id>
anz session list --realm <r> --username <u>
anz session revoke --realm <r> --username <u>
anz session cleanup
anz serve
docker pull ghcr.io/navicore/anz:latest
docker run -v ./anz.toml:/etc/anz/anz.toml -v ./data:/data -p 8080:8080 \
ghcr.io/navicore/anz --config /etc/anz/anz.toml serveCreate a GitHub release with a tag like v0.2.0. The workflow automatically:
- Runs CI checks
- Bumps
Cargo.tomlversion to match the tag and commits to main - Generates a CycloneDX SBOM from Cargo.lock
- Builds and pushes a Docker image to GHCR with SBOM and SLSA provenance attestations
- Signs the image and attaches the Cargo SBOM via Cosign (keyless, GitHub OIDC)
Required repo secret: PAT (GitHub token with contents: write).
Verify a release (replace tag with actual version, or use @sha256:... digest for strongest guarantee):
cosign verify \
--certificate-identity-regexp "https://github.com/navicore/anz/.github/workflows/release.yml@refs/tags/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/navicore/anz:0.3.0
cosign verify-attestation --type cyclonedx \
--certificate-identity-regexp "https://github.com/navicore/anz/.github/workflows/release.yml@refs/tags/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/navicore/anz:0.3.0The identity uses refs/tags/.* because releases are triggered by tag creation — the OIDC token GitHub mints for the workflow records the tag ref, not main.
All GitHub Actions are pinned to commit SHA (not version tags) to prevent supply chain attacks via tag mutation.
# run the same checks as CI (format, clippy, tests, release build)
just ci
# format + build + test
just devCI runs just ci — the justfile is the single source of truth. Linux on PRs, macOS on merge to main.
Requires just and a Rust toolchain.