The realm's sign-in gate and its only token issuer.
A realm is one deployment of this plane: one auth-service, one signing key pair, and the services that trust it. A token issued here is accepted by every service in that realm and by no other.
In the local realm: port 3003, published by the gateway at /v1/auth.
From the workspace root: just start auth · just logs auth · just restart auth.
See the workspace README for the whole map and
AGENTS.md for the rules every service here follows.
An identity token and nothing more:
iss the realm's issuer URL sub the platform user id
aud the realm's name iat / exp
jti unique, so revocation is possible later
type human | service | assistant
Organisation and role are deliberately absent. Membership changes while a token lives, so a token carrying a role goes stale and lies. A service that needs org context asks the org service and caches the answer briefly.
With ES256 the private key never leaves auth-service. Everyone else verifies
using the public key from /.well-known/jwks.json, and therefore cannot mint
anything.
The shared HMAC secret this replaces gave every holder the power to issue
tokens, not just check them — and that power was being used: a product API in
this plane minted { sub: <another user>, type: 'user' } to act on that user's
behalf. There is deliberately no endpoint here that mints a token for an
arbitrary subject. What replaced those uses is narrower:
| Need | Endpoint | Limit |
|---|---|---|
| A service acting as itself | POST /service-token |
Subject is the authenticated client; it cannot be chosen |
| An assistant or CI job | POST /exchange |
Same subject as the caller, one org, at most an hour |
| Acting as another user | — | Not offered. Grant the service the permission instead |
Add @baseworks/auth and let the middleware do the work. Your code never names
a key or an algorithm:
import { requireAuth } from '@baseworks/auth/hono'
app.use('*', requireAuth)
app.get('/things', (c) => {
const { userId, tokenType } = c.get('auth')
})AUTH_JWKS_URI=https://auth.example.com/v1/auth/.well-known/jwks.json
AUTH_ISSUER=https://auth.example.com
AUTH_AUDIENCE=example-realm
Set the issuer and audience in production. Without them, a token from any realm whose key set you happen to fetch will verify.
Needing org context? Do not look for it in the token — it is not there, on purpose. Ask the org service and cache the answer briefly.
import { addAuthCommands } from '@baseworks/auth/cli'
import { createConfigManager } from '@baseworks/config'
const config = createConfigManager<{ token?: string }>('mycli', {}, {
format: 'toml',
scope: 'global', // ~/.mycli/config.toml, 0600 — never inside a working tree
})
addAuthCommands(program, {
cliName: 'mycli',
authBase: () => 'https://auth.example.com/v1/auth',
onToken: (token) => config.patch({ token }),
onLogout: () => config.patch({ token: undefined }),
getToken: () => config.load().token,
tokenOrigin: () => config.writePath,
})$ mycli login
Approve this sign-in in your browser:
https://auth.example.com/v1/auth/token?state=dd6c62cb…
Waiting for approval — the link is valid for 10m…
✓ Logged in.
$ mycli auth status
user 01a05840-b6aa-7421-80d7-6eb192551584
token type human
expires 2026-09-13 14:33 UTC (in 23h 57m)
For cron jobs, queue workers and anything else acting on its own behalf:
curl -sX POST https://auth.example.com/v1/auth/service-token \
-H 'content-type: application/json' \
-d '{"client_id":"billing","client_secret":"'"$BILLING_AUTH_SECRET"'"}'The subject is the authenticated client and cannot be chosen. If you find yourself wanting a token for a user from a backend, that is the thing this service deliberately does not do — give the service its own identity and grant that identity the permission it needs.
curl -sX POST https://auth.example.com/v1/auth/exchange \
-H "authorization: Bearer $USER_TOKEN" \
-H 'content-type: application/json' \
-d '{"org_id":"org_1","scope":"read","expires_in":600}'Same subject, one org, at most an hour. org_id is a restriction, not a grant:
the receiving service still checks membership, because auth knows nothing about
organisations.
| Variable | Meaning |
|---|---|
AUTH_SIGNING_KEY |
EC P-256 private key, PKCS#8 PEM or base64 of it |
AUTH_SIGNING_KID |
Override the derived key id; rarely needed |
AUTH_RETIRED_PUBLIC_KEYS |
SPKI PEMs still published during a rotation |
AUTH_ISSUER |
Expected iss; defaults to APP_PUBLIC_URL |
AUTH_AUDIENCE |
Realm name; defaults to the issuer's hostname |
AUTH_SERVICE_CLIENTS |
name:secret,name:secret for /service-token |
JWT_SECRET |
Legacy. HS256, accepted while set — see below |
AUTH_CLI_APPROVE_URL |
Where /start sends a person to approve a CLI sign-in; defaults to this service's own page |
AUTH_RETURN_ORIGINS |
Extra origins returnTo may point at, comma-separated; APP_PUBLIC_URL is always allowed |
AUTH_DEV_MODE |
1 to sign in with a password instead of the IdP — dev mode; never in production |
AUTH_DEV_USER · AUTH_DEV_PASSWORD |
The dev account; default admin and a password made up at boot |
AUTH_SESSION_SECRET |
Key for the session cookie's signature; defaults to one derived from JWT_SECRET, else AUTH_SIGNING_KEY |
OIDC_*, APP_PUBLIC_URL, IAM_SERVICE_URL, KV_CACHE_URL |
Unchanged |
Base64 is accepted for key material because environment variables, Kubernetes Secrets and CI variables all mangle the newlines a PEM needs.
pnpm --filter @dotlabshq/auth-service keygen
Prints a fresh pair to stdout and never to a file: a private key written to disk gets committed, backed up and copied. The key id is derived from the key itself (RFC 7638 thumbprint), so it is not something to keep in step by hand.
- Generate a new pair.
- Put the old public key in
AUTH_RETIRED_PUBLIC_KEYSand the new private key inAUTH_SIGNING_KEY. Both are now published; new tokens use the new key. - After the longest token lifetime has passed, drop the retired key.
oidc_session carries an HMAC tag, because /token and /approve mint a
token for whoever it names. The key comes from AUTH_SESSION_SECRET if set,
else from JWT_SECRET, else from AUTH_SIGNING_KEY — so changing whichever
of those is in use signs every browser out once. Rotating the signing key and
step 5 below both do it. Set AUTH_SESSION_SECRET to make the cookie
independent of both.
Order matters, and each step is safe on its own:
- Deploy this service without
AUTH_SIGNING_KEY. It keeps issuing HS256 and behaves exactly as before. - Upgrade verifiers to
@baseworks/auth0.3.0 or later and give themAUTH_JWKS_URI. They accept both algorithms whileJWT_SECRETis set. - Set
AUTH_SIGNING_KEYhere. New tokens are ES256; old ones keep verifying until they expire. - Move anything that was minting with the secret onto
/service-token. - Remove
JWT_SECRETeverywhere. Only then is the issuing power actually gone.
Full contract: openapi.yaml, also served at /openapi.yaml.
Routes live at the service root. Behind a gateway the realm is reached at
/v1/auth/..., with that prefix stripped before the request arrives.
GET /healthz · GET /.well-known/service · GET /.well-known/jwks.json |
Discovery, unauthenticated |
GET /login · /oidc/callback · /session · /token |
Browser sign-in |
POST /login |
Dev mode only: the password form posts here |
GET /logout · POST /logout |
Sign out by navigation (redirects to returnTo), or by fetch() (204) |
GET /start · GET /poll/:state · POST /approve |
CLI device flow |
POST /service-token · POST /exchange |
Tokens that are not a browser login |
/start returns a handle and the URL to approve; poll /poll/{state} until it
answers done. Poll until exactly expires_in and no longer — a shorter client
timeout reports failure while the link still works. An unknown handle and an
expired one both answer expired, because from the client's side they mean the
same thing: nobody approved in time.
Bodies are snake_case (ADR-043), which the OAuth2 fields — token_type,
expires_in, client_id — already were; the plane had been mixing the two.
The session cookie keeps its own shape. It is storage, not a contract, and renaming its keys would invalidate every session already in a browser — signing everyone out to tidy up a field name.
- No revocation. A token is valid until it expires;
/logoutclears cookies only. Every token carries ajtiso a deny list can be added without a format change. - Login state lives in the cache. With no
KV_CACHE_URLthe service falls back to an in-process cache, which is correct for one replica and wrong for several:/startand/pollwould land on different pods and the flow would break. Bind a Redis cache before scaling out.
pnpm dev # tsx watch, reads .env.local
pnpm test # vitest
pnpm build # tsup
A realm on a laptop should not need a Zitadel project before anyone can open
the console. Set AUTH_DEV_MODE=1 and /login becomes a username/password
form; the account is printed when the service starts:
auth-service: DEV MODE — no identity provider, password sign-in
sign in at http://localhost:8080/v1/auth/login
user admin
password 1cbbrmMplLFHRcmp
The password is new on every start unless AUTH_DEV_PASSWORD fixes it;
AUTH_DEV_USER renames the account. Past the form everything is the ordinary
path — the same signed session, the same identity token, the same IAM /sync
— so the console and bw login work unchanged. The identity is always
urn:baseworks:dev + the username, so with IAM present it keeps one user id,
and its memberships, across restarts.
In dev mode /oidc/callback answers 404 and /logout?sso=1 is a local logout.
Under NODE_ENV=production the service refuses to start with
AUTH_DEV_MODE set: a realm entered with a password from an env var has no
identity provider, and that must never be one flag away from production.
/healthz says dev: true and /.well-known/service lists dev-login in
place of oidc-login, so nothing has to guess.
Build the image from the monorepo root:
podman build -f projects/auth-service/Dockerfile.workspace -t auth-service:<tag> .
The standalone Dockerfile is deprecated: npm ci cannot resolve the
workspace:* dependencies this service now uses.