Getting started
Central identity for the Zanii product ecosystem. One account works across every product, on unrelated domains, without third-party cookies.
Products never see a password. They redirect to ids.zanii.agency, get back an authorization
code, and exchange it for tokens they validate offline against a published JWKS.
Why this exists
Running separate auth in every product means duplicated login screens, duplicated password storage, and no way to answer "which of my products has this user already signed up for." Centralising authentication solves that, but only if the centre is worth trusting:
- Credential blast radius collapses. Registration happens on hosted pages here, so no product ever handles a password.
- One immutable
zanii_user_id. Products JIT-provision local rows keyed by it. Email is a mutable login handle, never the join key. - SSO across unrelated domains with no third-party cookies.
/authorizeis always a top-level navigation, so the session cookie is first-party by construction.
How a login works
sequenceDiagram
autonumber
participant U as User
participant P as product-b.com
participant I as ids.zanii.agency
U->>P: Continue with Zanii ID
P->>I: GET /authorize (PKCE S256 + state + nonce)
Note over I: __Host- session cookie is first-party<br/>on this top-level navigation
I-->>U: 302 back with ?code= (no login form if already signed in)
U->>P: /auth/callback?code=..&state=..
P->>I: POST /token (code + secret + verifier)
I-->>P: id_token · access_token (10 min) · refresh_token
P->>P: Verify offline, JIT-provision, set own session
Second product, same user, zero typing. Existing session or not, the only difference is whether a login form renders.
Quickstart
cd services/identity-server
pip install -e ".[dev]"
uvicorn app.main:app --port 8000
Dev defaults to SQLite and an in-memory rate limiter, so nothing else is required. For the full stack:
docker compose -f infrastructure/docker-compose.yml up --build
Register a product:
curl -X POST http://localhost:8000/manage/v1/clients \
-H "X-Admin-Token: dev-admin-token" -H "Content-Type: application/json" \
-d '{"client_name":"My Product","client_type":"confidential","first_party":true,
"redirect_uris":["http://localhost:9001/auth/callback"]}'
The response carries client_secret and webhook_secret exactly once.
Registration on the hosted pages requires the 18+ confirmation; the ledger integration is
off until an agent identity and API key are configured (see docs/configuration.md).
Integrating
Python — pip install zanii-id
from zanii_id import ZaniiClient
from zanii_id.integrations.fastapi import build_auth_router, install_zanii, require_zanii_auth
zanii = ZaniiClient() # reads ZANII_* env vars, validated eagerly
install_zanii(app, zanii, session_secret=SECRET)
app.include_router(build_auth_router(zanii, session_secret=SECRET))
@app.get("/dashboard")
async def dashboard(user = Depends(require_zanii_auth)):
return {"zanii_user_id": user.zanii_user_id}
Node — npm install @zanii-id/sdk
import { ZaniiClient } from "@zanii-id/sdk";
import { buildAuthRouter, requireAuth } from "@zanii-id/sdk/express";
const zanii = new ZaniiClient();
app.use("/auth", buildAuthRouter(zanii, { sessionSecret }));
app.get("/dashboard", requireAuth(zanii, { sessionSecret }), (req, res) =>
res.json({ zanii_user_id: req.zaniiUser.id }),
);
Next.js gets createAuthHandlers() as a drop-in app/auth/[...zanii]/route.ts. The browser
button is @zanii-id/react, which refuses to start if a client secret reaches it.
Agent activity
Every user gets a custodial did:key, exposed as a did claim. Products in the Zanii agent
ecosystem stamp each receipt with a platform-scoped subject tag derived from it, so a user can
audit everything agents did on their account — across every product — from one page.
Two lines on the product side:
from zanii_id.activity import subject_tag # pip install 'zanii-id[subject]'
tag = subject_tag(user.did, client_id) # pass to record(..., subject_tag=tag)
The user opens /ui/activity and gets a merged, chronological feed. Nothing on that page is
taken on trust: every receipt is verified offline against its signature, its delegation chain,
its scope, and the tag it was fetched under.
flowchart LR
A["Platform stamps<br/>a receipt"] --> B{"HMAC<br/>webhook"}
A --> C{"Subject slice<br/>pull"}
B --> D["Signature ·<br/>delegation · scope"]
C --> D
D --> E["Merkle inclusion<br/>+ signed tree head"]
E --> F["Custody verdicts<br/>in the browser"]
F --> G["verified"]
F --> H["flagged<br/>with a reason"]
A failed check never removes a row. It renders it flagged, with the reason, because silence would be indistinguishable from "nothing happened" — which is the exact claim the ledger exists to make checkable.
Users can also sign a portable "this slice is mine" claim, rotate their subject DID with a signed succession record, and inspect any agent's verified history.
docs/verification.md documents all ten verification layers, what
each one guarantees, and — deliberately — what is not verified.
Security posture
| Threat | Mitigation |
|---|---|
| Code interception | PKCE S256 required for every client, confidential included; 60 s single-use codes; replay revokes the downstream token family |
| Refresh token theft | Rotation on every use, hashed at rest, whole-family revocation on reuse |
| Access token theft | 10 minute TTL, jti denylist honoured at /userinfo, RFC 7009 revocation |
| Open redirect | Literal URI registry, exact match, no wildcards |
| Session fixation | Fresh session ID minted at every authentication |
| Credential stuffing | Argon2id, per-account and per-IP throttling, constant-shape verification |
| Account enumeration | Registration and password reset respond identically for known and unknown addresses |
| Alg confusion | alg pinned from discovery; key selected only by a trusted kid |
Third-party clients get a consent screen listing every requested scope and naming the organization that operates the app; grants are recorded per scope-set, so a newly requested scope re-prompts.
What users can do for themselves
/ui/account is the user's own control panel, no support ticket needed:
- change name, move to a new email (confirmed at the new inbox before anything switches), change password (every other session and every app token is revoked; the current one stays);
- two-step verification with any authenticator app, plus eight one-time recovery codes;
it also gates password reset, and every id_token says whether it was used (
amr); - see every connected app and the organization behind it, and revoke one - its tokens die and it has to ask again;
- see and end sessions, or sign out everywhere else;
- back up their identity key with a passkey: encrypted in the browser under a key the passkey derives (WebAuthn PRF), so Zanii never sees the file and the user can leave with their key;
- have their age verified through a configured provider, and see exactly what apps get
(
age_overwith the method, or onlyage_declaredfrom the sign-up gate); - delete the account, which tells every product they signed in to.
For agents and machines
- Client credentials for a service acting as itself.
- Token exchange (RFC 8693): an app that holds a user's token can obtain one for another
Zanii service on behalf of that user. The result names the actor in
act, keeps the audience's pairwisesub, never carries a refresh token, and is receipted on the user's ledger tag. An AI agent presents its own client-credentials token asactor_token, so "who did this for whom" is in the token itself. - Device flow (RFC 8628) for CLIs, TVs and headless agents: the user approves a short
code at
/devicefrom any signed-in browser. - DPoP (RFC 9449): tokens bound to the client's key; a stolen token is useless.
- PAR (RFC 9126) and private_key_jwt: no parameters through the browser, no shared secrets for clients that register a JWK Set.
- Back-channel and front-channel logout: relying parties are told when a session ends, server-to-server or through the user's browser.
- Know-your-agent: an organization can refuse named agents; token exchange screens the
actor against the audience's list before minting, and receipts the decision either way.
/orgs/agents/screenanswers about any counterparty DID with its ledger history. - Agent-to-agent login: two agents from two companies authenticate to each other with a
mutual, receipted screening; the token says
kya: {ok, mutual}and no human is involved. - Proof-carrying consent: every token names the ledger receipt of the consent that
authorised it (
cst);/introspectreturns that consent;zanii-id verify <did>checks an agent's whole history against the rulebooks it was stamped with, offline. - A public page per agent at
/agents/{did}: verified history next to who registered it. - Verifiable credentials (OID4VCI): a user can carry a signed
ZaniiIdentityCredentialin any OpenID4VC wallet, bound to their key and naming their subject DID.
Discovery at /.well-known/openid-configuration advertises all of it; the credential
issuer metadata lives at /.well-known/openid-credential-issuer. The SDKs (0.3.0) cover
every grant above, DPoP, PAR, private_key_jwt, both logout channels, the receipt relay and
the organization API, and are built to be cheap for the issuer: cached discovery and keys,
a timeout on every call, token grants never retried.
Built on the ledger's own primitives
Zanii ID does not just write receipts; it uses the ecosystem's shapes so anyone can verify
them with the published packages. Consent is a consent.granted receipt, deletion a
data.retention.deleted attestation plus a retention hold, every receipt names the
deployed commit (runtime_hash) and the signed operating rules (manifest_hash) that
forbid an operator from ever setting an email or reading a password. Organizations hold a
DID, sign their terms acceptance with it, and can bind it to their domain, which the
consent screen shows. Users can download a bilingual court-ready evidence pack of what
Zanii ID did on their account. A sentinel watches the IdP's own receipts under a separate
key. The IdP's key has a post-quantum binding anchored on the ledger.
Step-up, passkeys, recovery
- Passkeys. Users add Touch ID, Windows Hello or a security key on their account page
and sign in without a password. A passkey session carries
amr: ["webauthn"]. - Step-up. Send
acr_values=urn:zanii:mfaon a sensitive route and Zanii ID guarantees the returned session has a second factor, challenging the user in place if they are enrolled and telling your app (unmet_authentication_requirements) if they are not.prompt=loginandmax_ageforce a fresh login;prompt=nonenever interacts. - Hijack protection. An email change notifies the old address with a link that reverts it and signs out every device.
- Recovery. A user who loses both authenticator and recovery codes is restored by an operator through an audited, receipted reset, never by a hidden backdoor.
Lifecycle webhooks
Register a lifecycle_webhook_url and Zanii ID POSTs user.deleted,
user.password_changed, user.email_changed and consent.revoked to it, signed with the
same webhook_secret you already verify. Deliveries are queued in the same transaction as
the change and retried with backoff, so a deletion is never lost because your endpoint
was down for a minute. The sub in the payload is the one you know the user by.
Receipts a platform relays back are matched to the user through an index written at token issuance, so ingestion stays one lookup however many users the platform has.
Using Zanii ID from outside the ecosystem
Any OIDC library works - the SDKs are convenience, not a requirement. An outside company registers itself and manages its own clients without involving Zanii:
curl -X POST https://ids.zanii.agency/orgs/signup \
-H "Content-Type: application/json" \
-d '{"name":"Acme","contact_email":"dev@acme.example","accept_terms":true}' # -> api_key, shown once
curl -X POST https://ids.zanii.agency/orgs/clients \
-H "Authorization: Bearer zid_org_..." -H "Content-Type: application/json" \
-d '{"client_name":"Acme App","client_type":"confidential",
"redirect_uris":["https://app.acme.example/auth/callback"]}'
Clients registered this way are pairwise: every client in the same organization sees
one stable sub for a user, and no other organization can derive it - so two unrelated
companies cannot join their user tables to correlate a person. Zanii's own products keep
the global zanii_user_id. Pairwise clients do not receive the did claim, for the same
reason.
Prefer a screen to curl? https://ids.zanii.agency/console does the same thing: register,
log in with the org key, create apps, see secrets once, rotate them, set the lifecycle URL.
Standard OIDC libraries can also self-register through POST /register (RFC 7591) with the org
key as the initial access token. Every organization starts with a quota of ten apps and thirty token requests a minute across
them; Zanii raises it on request. The terms, privacy policy and DPA are at /legal/* and
docs/legal/, published as drafts until counsel has reviewed them.
Production
Production mode is enforced rather than advisory. With ZANII_ID_ENVIRONMENT=production the
server refuses to start unless all of the following hold, and reports every problem at once:
| Variable | Requirement |
|---|---|
ZANII_ID_APP_SECRET |
not the dev default, ≥ 32 chars — encrypts signing keys and custodial DID keys |
ZANII_ID_ADMIN_TOKEN |
not the dev default |
ZANII_ID_ISSUER |
https:// |
ZANII_ID_DATABASE_URL |
not SQLite |
ZANII_ID_REDIS_URL |
required — the in-memory limiter is per-worker |
ZANII_ID_RESEND_API_KEY or ZANII_ID_SMTP_HOST |
a mail transport is required — without one, verification and password reset silently do nothing. Prefer the HTTPS key: many cloud hosts block outbound SMTP |
ZANII_ID_COOKIE_SECURE |
not false — it would drop the __Host- prefix |
Each of these fails quietly rather than loudly if left wrong, which is why they are start-up errors instead of documentation.
Traces are opt-in: ZANII_ID_OTEL_ENDPOINT exports OTLP/HTTP with one span per ledger
receipt, in the same vocabulary the products' zanii.otel wrapper uses.
Schema is owned by Alembic in production (alembic upgrade head); create_all runs only
outside it. Rotate signing keys roughly every 90 days — retired keys keep validating
outstanding tokens through the JWKS window.
Layout
services/identity-server FastAPI IdP — authorize · token · userinfo · logout · JWKS · hosted pages
packages/python-sdk zanii-id → PyPI, with FastAPI integration
packages/typescript-sdk @zanii-id/sdk → Node, Express, Next.js
packages/react-components @zanii-id/react → browser button, public client only
examples/ FastAPI · Express · Next.js reference apps
infrastructure/ docker compose (postgres · redis · server)
Tests
cd services/identity-server && python -m pytest tests -q # 255 tests: protocol, consent, MFA, passkeys, lifecycle, console, ledger, agents
cd packages/python-sdk && python -m pytest tests -q # 45 tests, SDK flows against a stub IdP
cd packages/typescript-sdk && npm test && npx tsc --noEmit # 82 tests: client, express, next, session, jwt, activity, verdicts
python scripts/gen_docs.py --check # configuration.md and openapi.json match the code
CI additionally verifies that migrations match the models, that the committed browser widget is reproducible from source, and that the container actually boots.
Documentation
Start at docs/README.md, the index. Highlights:
CONTRIBUTING.md— local setup, suites, migrations, conventions, releasing, deployingdocs/configuration.md— every setting with its default and production requirement, generated from the codedocs/openapi.json— the API as a machine-readable spec (served admin-gated at/openapi.json)docs/architecture.md— flows, schema, threat model, decisionsAD-1toAD-61docs/operations.md— deploy, backups, keys, ledger operations, incidentsdocs/verification.md— what is verified, how, and what is notCHANGELOG.md— server plans and SDK releasesskills/zanii-id/— an Agent Skill so a coding assistant integrates this correctly without being re-taught OAuth each session
Licence
The three client SDKs (zanii-id, @zanii-id/sdk, @zanii-id/react) are Apache-2.0, matching
the rest of the Zanii ecosystem. Everything else in this repository — the identity server
above all — is proprietary. See LICENSE.