Authentication and user-management service. Deploys as a single container; pulls a pinned image, points at a Postgres datastore, exposes Connect-RPC over HTTP/JSON.
Treat this like tenant-shard-db: one image, deployed once per product, fully isolated user pools.
v1.0 is the Project/Tenant/Domain redesign. Upgrading from a pre-v1.0 deployment is a breaking schema reset — there is no in-place data migration in this release; a fresh install is the supported path. See
docs/UPGRADE.mdfor the full upgrade guide. Postgres row-level-security defense-in-depth ships in v1.0 (migration0016_enable_rls_data_plane). What changed:
OrganizationSignupremoved, along with theOrganization/OrganizationMembershiptables. Multitenancy is now modelled by Projects (the isolation shard) with Tenants auto-formed from verified email domains inside a project.mode=single | multiremoved, together with its env vars (GATEWAY_IDENTITY_MODE,GATEWAY_TENANT_HOST_BASE_DOMAIN,GATEWAY_TENANT_RESOLUTION_SOURCES). One code path now resolves the project per request (from anX-Project-Keycredential or theHostheader), then the tenant from the user's email domain.- Data-plane storage re-keyed
tenant_id→project_id(ADR-0002).TenantAdmin/RepositoryForTenantembedding options removed; embedders injectRepo+DBand the per-project repository is resolved internally.See
docs/IDENTITY.mdand the ADRs underdocs/adr/for the model in full.
- Email + password signup, login, change, reset, lockout
- Passkeys (WebAuthn) registration and login
- TOTP (2FA) setup, verify, recovery codes
- QR cross-device login
- OAuth login (Google, Microsoft, GitHub, Apple — server-owned authorization-code exchange)
- Sessions with revoke and sign-out-everywhere
- JWT issuance with key rotation, plus
/.well-known/jwks.jsonfor downstream services - User and Group CRUD, group membership data
- Audit log of auth events
- Email and phone verification flows
Identity authenticates users, issues JWTs, and assigns coarse roles
(admin / member / guest). It stores groups and memberships, but it
does not enforce per-group or per-resource ACLs. Calling applications are
responsible for authorization decisions built on that data.
Identity persists to Postgres — the primary datastore, which carries
the Project/Tenant/Domain control plane. An in-memory driver
(GATEWAY_REPO_DRIVER=memory) is available for local development and tests.
Both drivers implement the same graph-shaped repository contract and are
held to one conformance suite (identical
uniqueness/ordering/error-translation semantics across every method).
The service models its data as a small set of graph node types, addressed
by stable numeric type_id. The relational drivers map these onto tables;
the service layer treats node IDs as opaque strings. Current allocations:
| type_id | Node | Notes |
|---|---|---|
| 1 | User | email is unique |
| 2 | WorkingGroup | identity's group |
| 5 | RefreshToken | token_hash is unique |
| 19 | PasswordResetToken | token_hash is unique |
| 20 | PasskeyCredential | credential_id is unique |
| 21 | PasskeyChallenge | one-time per ceremony |
| 22 | QrLoginSession | session_id is unique |
| 23 | TotpCredential | per-user TOTP secret |
| 24 | RecoveryCode | code_hash is unique |
| 25 | LoginChallenge | challenge_id is unique |
| 26 | AuditEvent | append-only |
| 27 | UserInvitation | token_hash, email unique |
| 28 | AdminHelpRequest | |
| 29 | EmailVerificationToken | token_hash is unique |
| 30 | EmailChangeToken | token_hash is unique |
| 31 | OAuthIdentity | (provider, provider_user_id) is composite unique |
| 32 | IdentityVerification | verification_id is unique |
| 35 | Session | sid is unique |
| 36 | OAuthOneTimeCode | hosted-OAuth handover; code_hash unique |
| 37 | EmailLoginCode | passwordless OTP; email unique |
| 38 | MagicLinkToken | passwordless magic link; token_hash unique |
| 39 | PhoneVerificationCode | SMS OTP; per-user |
Type IDs 33 and 34 (formerly Organization / OrganizationMembership)
are retired and unallocated — organizations were dropped in v1.0. Project,
Tenant, Domain, and membership state live in dedicated Postgres tables in
the control plane and per-project data plane, not in the node-type registry
above.
All config is via environment variables. See internal/config/config.go for the full list. Most-tweaked:
| Var | Purpose |
|---|---|
GATEWAY_REPO_DRIVER |
Persistence backend: postgres (default), sqlite, or memory (local dev / tests) |
GATEWAY_POSTGRES_DSN |
Postgres connection string (required for the postgres driver) |
GATEWAY_SQLITE_PATH |
SQLite database file path (when GATEWAY_REPO_DRIVER=sqlite). Required — set it to :memory: explicitly for an ephemeral in-process database; there is no implicit default. File-backed databases open in WAL mode (synchronous=NORMAL) so concurrent reads don't serialize behind writes |
GATEWAY_SQLITE_MAX_CONNS |
Connection-pool size for a file-backed SQLite database (default 4). Ignored for :memory:, which is pinned to a single connection |
GATEWAY_DEFAULT_TENANT_ID |
Storage scope ID (the physical shard) the default project maps onto |
GATEWAY_DEFAULT_PROJECT_ID |
ID of the control-plane Project seeded on boot and used to pin zero-config requests (default default) |
GATEWAY_DEFAULT_PROJECT_AUTH_DOMAINS |
Comma-separated serving hostnames seeded (verified) onto the default project; the first is primary. Lets the Host header resolve to the default project |
GATEWAY_REQUIRE_VERIFIED_AUTH_DOMAIN |
When true (default), a project's primary auth-domain (which drives branded link URLs / cookie domains) must be DNS-verified — an unverified is_primary custom host is ignored. Set false to let an unverified primary host drive branded links |
GATEWAY_ADMIN_API_SECRET |
Shared secret authenticating the control-plane admin RPCs (AdminCreateProject, …). Empty (default) disables them |
GATEWAY_PUBLIC_EMAIL_DOMAINS |
Extra consumer/public email domains never auto-formed into a tenant (adds to the built-in gmail/outlook/… set) |
GATEWAY_JWT_SIGNER |
JWT signer backend: file (default) or kms_aws |
GATEWAY_JWT_KEYS_FILE |
Path to the file-backed signer's keys file (see docs/key-rotation.md) |
GATEWAY_JWT_KMS_KEYS |
AWS KMS signer: CSV of kid=arn entries |
GATEWAY_PASSKEY_RP_ID |
Passkey relying party ID — must match your domain |
GATEWAY_PASSKEY_ORIGIN |
Allowed origin for passkey ceremonies |
GATEWAY_TOTP_ISSUER |
Name shown in user authenticator apps |
GATEWAY_DEFAULT_EMAIL_DOMAIN |
Default email domain for new accounts |
GATEWAY_ALLOWED_ORIGINS |
CORS origins |
GATEWAY_AUTH_ALLOW_LOCAL |
Set false in prod to disable username/password if you only want OAuth |
Pull the image and run it alongside a Postgres instance. Postgres carries
the Project/Tenant/Domain control plane; the memory driver pins every
request to the default project (no control-plane project registry), so it
suits local dev and tests rather than production.
# 1. Run Postgres first
docker run -d --name identity-db -p 5432:5432 \
-e POSTGRES_USER=identity -e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=identity \
postgres:16.13-alpine3.23
# 2. Run identity pointing at it (apply migrations once, then serve)
docker run -p 80:80 -p 9090:9090 \
-e GATEWAY_REPO_DRIVER=postgres \
-e GATEWAY_POSTGRES_DSN='postgres://identity:password@identity-db:5432/identity?sslmode=disable' \
-e GATEWAY_POSTGRES_AUTO_MIGRATE=true \
-e GATEWAY_DEFAULT_TENANT_ID=my-product \
-e GATEWAY_PASSKEY_RP_ID=my-product.com \
-e GATEWAY_PASSKEY_ORIGIN=https://my-product.com \
-e GATEWAY_TOTP_ISSUER="My Product" \
ghcr.io/elloloop/identity:0.1.0In production, run migrations out-of-band as a separate step
(identity migrate) rather than relying on GATEWAY_POSTGRES_AUTO_MIGRATE
on a rolling deploy. Or use docker-compose.yml at the repo root — it
wires identity to Postgres with a persistent volume and a health-gated
depends_on.
For embedded, single-node, and development deployments, identity ships a
pure-Go SQLite driver (modernc.org/sqlite,
no cgo — cross-compiles cleanly). It runs the per-project data plane in a
single file (or fully in-process) with no external service. Like the entdb
and memory drivers it is single-project (pinned to the default project — the
Project/Tenant/Domain control plane is Postgres-only); unlike them it
persists to durable SQL. Select it with GATEWAY_REPO_DRIVER=sqlite:
docker run -p 80:80 -p 9090:9090 \
-e GATEWAY_REPO_DRIVER=sqlite \
-e GATEWAY_SQLITE_PATH=/data/identity.db \
-e GATEWAY_DEFAULT_TENANT_ID=my-product \
-v identity-data:/data \
ghcr.io/elloloop/identity:0.1.0Set GATEWAY_SQLITE_PATH=:memory: for an ephemeral in-process database
(tests / throwaway dev). The SQLite driver passes the same conformance
suite as Postgres and memory — identical uniqueness, ordering, and
ErrNotFound/ErrAlreadyExists/ErrInvalidArgument semantics. There is no
Row-Level Security on SQLite (that stays Postgres-only defense-in-depth); the
mandatory WHERE project_id = $1 repo boundary is the backend-agnostic
isolation.
Backend tiers at a glance:
| Driver | GATEWAY_REPO_DRIVER |
Use case | Control plane |
|---|---|---|---|
| Postgres | postgres |
Production, multi-node | Yes (Project/Tenant/Domain) |
| SQLite | sqlite |
Embedded, single-node, dev | No (single-project) |
| EntDB | entdb |
tenant-shard-db deployments | No (single-project) |
| memory | memory |
Tests, smoke | No (single-project) |
Push a v* tag — .github/workflows/release.yml builds and pushes a multi-arch image to ghcr.io/elloloop/identity:<version>.
git tag v0.1.0
git push origin v0.1.0go build ./...
go test ./...