A lightweight authentication server where who exists is a file, not a database row. You declare users and groups (with recursive group inheritance) in two YAML files; DeclarativeAuth hot-reloads them and exposes that identity through both an LDAP server and an OIDC provider + hosted login page, so any downstream system can authenticate against it. Postgres is only used for what YAML can't express: password hashes, sessions, password-reset tokens, audit history, and brute-force lockouts. Everything else -- how the process itself runs -- is configured through environment variables.
users.yaml / groups.yaml (declarative, hot-reloaded, "who exists")
|
v
DeclarativeAuth <-----> Postgres (password hashes, sessions, logs)
/ | \
/ | \
LDAP OIDC web login/reset page
server provider (+ small admin UI)
Managing users using a UI is nice & pretty, until you need to do something with that data.
Config file can be versioned, edited, generated, shared however you like - it's just files, not database records.
This is a lightweight & secure alternative to hard to heavy (authentik), hard to maintain (kanidm) or basic (lldap) auth systems.
- Declarative identity --
users.yaml/groups.yaml, hot-reloaded, with recursive group inheritance (diamond-safe, cycle-detected). - Single static binary, ~27MB container image, a few MB of RSS at idle.
- Passkey (WebAuthn) support
- Username or email, interchangeably, both resolve to the same account.
- OIDC provider, authorization code + PKCE
- LDAP v3 server (simple bind + search) with fully flattened
memberOf, so LDAP-only clients don't need to understand nested groups. - Insecure-configuration guards: the browser refuses to submit any password field at all when the page wasn't loaded over a secure connection. The server logs an explicit warning at startup for insecure configurations.
- SMTP-based password reset & MFA: configurable minimum length & strength policy, group & per-user enforceable MFA (users can still opt in even when not enforced, but they can't opt out if they are).
- Persisted brute-force backoff, shared across LDAP and OIDC/web login.
- Environment-variable configuration
- Hardened password storage: Argon2id.
- Reverse-proxy aware: correct client IP/scheme handling for rate limiting and audit logs when deployed behind a load balancer.
- Observability: Prometheus metrics, structured JSON logs,
/healthzand/readyz. - Admin UI (gated by a group setting): send test emails, view the group-inheritance graph, and optionally edit/save the declarative files from the browser (can be disabled, single-instance deployments only (might change in the future)).
This spins up DeclarativeAuth plus Postgres and a
mailcatcher (so you can see password-reset
emails without a real SMTP server) using the example identity in
examples/identity/:
cp deploy/compose/.env.example deploy/compose/.env
docker compose -f deploy/compose/docker-compose.yaml up -d --build| What | Where |
|---|---|
| Home page (name/email, passkeys) | http://localhost:8080/ |
| Login page | http://localhost:8080/login |
| Password reset | http://localhost:8080/reset |
| Admin UI | http://localhost:8080/admin |
| Caught emails (mailcatcher) | http://localhost:1080 |
| Prometheus metrics | http://localhost:9090/metrics |
| LDAP | ldap://localhost:1389 |
The example identity declares users but no passwords. You can either use the forgot password form, or set one via CLI:
docker compose -f deploy/compose/docker-compose.yaml exec declarativeauth \
/declarativeauth admin set-password \
-dsn "postgres://declarativeauth:declarativeauth@postgres:5432/declarativeauth?sslmode=disable" \
-username jsmith -password Secret123!Then log in at http://localhost:8080/login with jsmith / Secret123!,
or query LDAP directly:
ldapsearch -x -H ldap://localhost:1389 \
-D uid=jsmith,ou=users,dc=example,dc=com -w Secret123! \
-b ou=users,dc=example,dc=com "(memberOf=cn=engineering,ou=groups,dc=example,dc=com)" uid memberOfjsmith isn't declared as a member of engineering directly (see
examples/identity/groups.yaml) -- this
search only returns a result because engineering is a transitive
grandparent via oncall -> backend-team -> engineering, and
DeclarativeAuth flattens that automatically.
Two separate kinds of input, deliberately different in shape:
- Identity (
users.yaml+groups.yaml) -- who exists, declarative YAML, hot-reloaded. Seeexamples/identity/: every field is commented inline, and the example groups form a deliberate "diamond" to demonstrate inheritance. Point the server at your own copy viaDECLARATIVEAUTH_IDENTITY_PATH; edits are hot-reloaded, no restart needed. - Server config -- how the process runs: listeners, database DSN,
SMTP, rate limiting, TLS, admin UI. Entirely
DECLARATIVEAUTH_*environment variables, set once at process start. See.env.exampleat the repo root for every variable, its default, and whether it's required.
Secrets (DB password, SMTP password) are just the value of their own
environment variable (DECLARATIVEAUTH_DATABASE_DSN with the password
embedded, DECLARATIVEAUTH_SMTP_PASSWORD) -- however you inject
environment variables into the process (docker run --env-file, a
Kubernetes Secret, a systemd EnvironmentFile=) is however you manage
these secrets; DeclarativeAuth has no config-templating layer of its own
to route around.
For a from-scratch production layout (Kubernetes + CloudNativePG), see
deploy/kubernetes/ -- configmap-env.yaml /
secret-example.yaml for the non-secret/secret environment variables
(consumed via envFrom), and configmap-identity.yaml for the same
identity example, mounted as a volume so it can hot-reload.
declarativeauth serve # run the server (default long-running process)
declarativeauth migrate # apply Postgres migrations and exit
declarativeauth validate-config # validate users.yaml/groups.yaml without starting anything
declarativeauth admin set-password # seed/reset a password directly (bootstrap/testing)
validate-config reuses the exact same parse+validate code path the
running server uses (so results never diverge), and prints a summary:
$ declarativeauth validate-config -identity-path examples/identity
config valid: 5 groups, 3 users (1 disabled)
Use it in CI or a pre-commit hook against your real identity files, before they ever reach a running server.
There is also an admin-only HTTP endpoint, POST /admin/send-setup-link
(gated the same way as the rest of /admin), that emails a newly-declared
user a first-password-setup link -- it reuses the identical token/email
flow as a normal password reset, so there's no separate "admin sets a raw
password" code path.
cmd/declarativeauth entrypoint + CLI subcommands
internal/ application code (see "Architecture" below)
examples/identity/ a worked users.yaml/groups.yaml example (diamond group inheritance)
deploy/
docker/ production Dockerfile
compose/ docker-compose quickstart stack + the Go toolchain dev container
kubernetes/ Deployment/Service/ConfigMap/Secret/Certificate/CNPG-Cluster examples
examples/.env.example every DECLARATIVEAUTH_* environment variable, with defaults/docs
test/integration/ integration tests (real Postgres, real SMTP, real LDAP client)
cmd/declarativeauth entrypoint + CLI subcommands
internal/config declarative identity YAML loading/validation/hot-reload, env-var server config
internal/identity domain model + group-inheritance resolver (cycle detection, flattening)
internal/store Postgres access layer (credentials, sessions, reset tokens, audit, lockouts, passkeys, email-MFA)
internal/auth password hashing, Authenticate(), MFAPolicy, brute-force backoff, client IP
internal/ldapserver minimal LDAPv3 server (Bind + Search only)
internal/oidcserver authorization-code+PKCE OIDC provider
internal/web login page, password reset flow, passkey (WebAuthn) flow, email-MFA flow, session cookie, CSRF
internal/admin gated admin UI (SMTP test, group graph, config editor)
internal/mail SMTP client + email templates
internal/tls certificate loading + hot rotation + self-signed dev fallback
internal/metrics Prometheus metric definitions
internal/logging log/slog setup
internal/server process composition: wires every listener together
The key architectural seam: LDAP bind and OIDC/web login both call the
exact same internal/auth.Authenticate, and both read group membership
from the exact same identity.Snapshot.FlattenedMemberOf. That's what
guarantees LDAP and OIDC present an identical view of identity.
LDAP and OIDC/web each have two independent listener addresses -- a
plaintext one and a TLS-terminating ("secure") one -- and either, both, or
neither can be active, entirely based on whether its address variable is
set: DECLARATIVEAUTH_LDAP_LISTEN_ADDR / DECLARATIVEAUTH_LDAP_SECURE_LISTEN_ADDR,
and DECLARATIVEAUTH_OIDC_LISTEN_ADDR / DECLARATIVEAUTH_OIDC_SECURE_LISTEN_ADDR.
There's no separate "TLS enabled" flag to keep in sync -- setting the secure
address is what turns TLS on for that listener, and running both at once
(e.g. a plaintext port for in-cluster traffic alongside a public TLS port)
is a supported, ordinary configuration.
- Self-terminated: set
..._SECURE_LISTEN_ADDRand provide a cert/key pair (either per-listener, e.g.DECLARATIVEAUTH_OIDC_TLS_CERT_FILE, or once via the sharedDECLARATIVEAUTH_TLS_CERT_FILE/_KEY_FILE). Certs are hot-reloaded from disk on change -- no restart needed to roll a renewed cert. If no cert/key is configured at all while the secure address is set, an ephemeral self-signed certificate is generated instead (a warning is logged; don't rely on this outside local dev). - Reverse-proxy terminated: only set
..._LISTEN_ADDR(leave the..._SECURE_LISTEN_ADDRunset) and put a TLS-terminating proxy/load balancer in front. Its address needs to be trusted forX-Forwarded-For/X-Forwarded-Protoheaders to be honored (rate limiting, audit logs, and detecting that the original request was HTTPS) -- by default DeclarativeAuth already trusts its own default gateway (DECLARATIVEAUTH_NETWORK_TRUST_DEFAULT_GATEWAY, true by default), which covers the common case of a reverse proxy on the Docker host or in a sidecar reaching the container through its bridge gateway. Add explicit CIDRs toDECLARATIVEAUTH_NETWORK_TRUSTED_PROXIESfor anything beyond that (e.g. a load balancer reachable on a different address), or setDECLARATIVEAUTH_NETWORK_TRUST_DEFAULT_GATEWAY=falseif nothing should be trusted implicitly.
Since "reverse-proxy terminated" is indistinguishable, from the
configuration alone, from someone simply forgetting to set up TLS at all,
the server logs an explicit WARN at startup whenever a listener's
plaintext address is set (among other risky settings -- see
"Insecure-configuration guards" below) so it's never silently the case.
There's no way to suppress these short of actually fixing the setting;
they're informational, not
fatal.
Two independent layers, since neither can catch everything alone:
- Startup warnings (
internal/server/insecure_warnings.go): logged once perdeclarativeauth servestartup for anything that could silently expose credentials -- TLS-disabled listeners,DECLARATIVEAUTH_LDAP_ALLOW_ANONYMOUS_BIND=true, a password policy weaker than the documented defaults, or the config editor enabled without OIDC TLS (which would expose both the admin session cookie and the identity files it can rewrite). These areWARNlogs, not startup failures -- some of them (e.g. TLS-disabled behind a reverse proxy) are entirely legitimate, so the server can't know for certain something's wrong, only flag what's worth double-checking. - Client-side guard (
internal/web/static/secure-guard.js, loaded on every login/reset/admin page): refuses to submit any form containing a password field unlesswindow.isSecureContextis true -- the browser's own definition of "safe to send secrets" (true for HTTPS, and also true forhttp://localhostso local dev without TLS still works). This is a last-resort net for the non-adversarial case -- a stale bookmark, a misconfigured proxy, a typo'd link landing someone on plain HTTP -- not a substitute for actually running TLS; a network attacker who controls the connection can also tamper with the JavaScript itself.
See CONTRIBUTING.md for local dev setup (a WSL/Docker dev container is used since this was built without a local Go toolchain), running tests, and adding migrations.