Zanii ID — patterns & gotchas (what models get wrong)
The mistakes that actually happen
-
🔴 Keying local users on email. The single most damaging one. Email is a mutable login handle;
subis the immutablezanii_user_id. Key on email and a user who changes it becomes a second account, while two products disagree about who they are. Upsert onsub, and store email as a display attribute refreshed on each login. -
🔴 Assuming you were granted the scope you asked for. Consent is per permission: the user can untick
emailoroffline_accessand still press Allow. Read the token response'sscope. Code that doestokens["refresh_token"]unconditionally now raises aKeyErrorthe first time someone declines to stay signed in, and code that readsclaims["email"]breaks the same way. Branch on what you actually received. -
Skipping PKCE because the client is confidential. Zanii ID requires
code_challenge_method=S256from every client. "We hold a secret, so we don't need PKCE" is true of plain OAuth 2.0 and false here —/authorizerejects the request. -
Retrying a failed token exchange. Codes are single-use with a 60 s TTL, and a replay after successful use is read as interception: it revokes the whole downstream refresh family. An automatic retry turns a transient network blip into a forced re-login. Never wrap
/tokenin a retry decorator. -
Retrying, or swallowing,
invalid_granton refresh. It means the family is gone — expired, revoked, or reuse was detected. The only correct response is to clear the local session and send the user through/authorizeagain. -
Storing tokens in
localStorage. Access tokens live 10 minutes and refresh tokens rotate; both belong in a server-side session or an HttpOnly cookie. A SPA that must hold them keeps them in memory only and re-authenticates on reload. -
Passing a client secret to the browser.
@zanii-id/reactthrows on any prop matchingsecret|password|credential, which catches it in development. Hand-rolling? A browser is a public client, full stop. -
Trusting the token endpoint instead of verifying. Always check the
id_tokensignature against the JWKS plusiss,aud,expandnonce. If you hand-roll, pinalgto RS256 from discovery — readingalgfrom the token header is the classic algorithm-confusion hole. -
Forgetting
nonce. Without comparing it to the value you sent, a replayed id_token is accepted. -
Checking
stateafter the exchange, or not at all. It must be bound to the user's session and compared before you spend the code, or your callback is a CSRF endpoint. -
Confusing
@zanii-id/*with@zanii/*.@zanii-id/sdkis identity;@zanii/subject,@zanii/coreand the rest are the accountability ledger. They interoperate, but@zanii/idis the old name and no longer correct. -
Calling
/userinfoon every request. It is a network round-trip. The id_token already carries the scoped claims; hit userinfo only for live data or to confirm a token is still valid. -
Registering a redirect URI that "looks right". Matching is exact — a trailing slash,
httpvshttps, or a different port is a different URI, and/authorizerefuses it with an error page rather than redirecting.
Integrating from outside the Zanii ecosystem
You do not need the global admin token or a conversation with Zanii. POST /orgs/signup,
then create clients under /orgs/clients with your org key. Two things follow from that
which an assistant will get wrong:
- Your users'
subis pairwise. It is stable for your organization but is not thezanii_user_id, and you cannot compare it with another company'ssubfor the same person. That is the point. Key your users on it exactly as you would the global id. - You will not receive a
didclaim, so the agent-activity stamping in this skill does not apply to external clients. Do not "fix" this by asking Zanii for the global id; it is withheld deliberately.
Consent and first_party
first_party: true means you own the product: no consent screen, the user is redirected
straight back. Set it false for anything you did not build — those clients get a screen
listing every requested scope, and a grant covers only the scopes it was given for, so
adding one later re-prompts.
Requesting openid profile email offline_access when you need openid email makes that
screen scarier and approval less likely. Ask for the minimum.
Hand-rolling what the SDK now does
Before 0.6.0 the agent surfaces had no SDK wrapper and the honest advice was to build the
authorization URL by hand. That advice is stale: login_hint, resource and task are
parameters on get_authorization_url / getAuthorizationUrl, and approvals, shared signals
and the organization surfaces all have methods. Hand-rolled URL building is now a source of
bugs the SDK would have caught — a task grant missing one of its four fields, for instance,
which the SDK rejects before the user is redirected.
The one thing still absent on purpose is the /manage/v1 admin API, because it takes the
global admin token and that token must not live in application code.
Task-bounded grants done wrong
- Treating
ceilingas enforcement. It is the user's declared limit, carried and proven, never checked by Zanii — we cannot see your spending. Your resource server has to refuse the transaction. Telling a user "Zanii caps this at AED 4,000" is a false statement about someone else's system. - Counting
uses_leftas API calls. One use is one token minted under the grant, which is the only thing the issuer can observe. If your agent makes fifty calls with one token, that is one use. Sizemax_usesaccordingly and say what it means in your own interface. - Asking for a task grant with three of the four parameters.
purpose,max_usesandgrant_ttlare all required together (ceilingis optional); a partial request isinvalid_request, deliberately, because half a description is not something a person can meaningfully approve. - Retrying past a spent grant.
invalid_grantnaming a spent, expired or revoked task grant is final. Ask the user for a new one; do not loop.
Approvals done wrong
- 🔴 Reading
expiredas approval. An unanswered request expires refused. A default of "proceed if nobody objected" is the exact failure an approval step exists to prevent. - Putting client credentials in
Authorization. That header carries the user's access token onPOST /approvals; the client authenticates in the body. Sending Basic auth there silently replaces the user token and you getinvalid_token. - Polling faster than
interval. You getslow_down, exactly as in the device flow.
Resource indicators and MCP
- Assuming
audis yourclient_id. Withresource=it is the resource identifier, andclient_idis a separate claim. A resource server should checkaudequals its own identifier — that is the entire point, and it is what stops a token from one tool server working at the next. - Passing a
resourceyou never registered. It isinvalid_target, not ignored. Registering it is what makes validation possible.
The did claim and subject tags
Every user carries a custodial did:key. To make your agent's actions auditable by that
user, stamp receipts with subject_tag(user.did, YOUR_CLIENT_ID).
- The platform id is your
client_id— not the issuer, not your domain. The same user gets a different tag per product, so tags cannot link a person across products. - Tags are unguessable but public. The ledger is public; the tag is the privacy boundary. Never publish a mapping from tag back to a user.
- The
didclaim lags rotation. After a user rotates their subject DID, tokens issued earlier keep the old value until re-issue. Zanii ID accepts predecessor tags during ingestion so nothing is dropped mid-migration — do not "fix" this by caching a DID forever; read it from the current token. - Not the same as
@zanii/healthepisode tags, which are deliberately unlinkable per episode. Subject tags are deliberately stable. Swapping them destroys the property each exists to provide.
Ledger shapes done wrong
- Calling "domain verified" a vetted company. It means the org's key and its domain agree on the entity name. Say that. Domain control is not corporate registration.
- Re-salting a consent receipt. They are recorded unsalted on purpose: the payload already commits the subject with its own salt, so the recorded hash is verifiable.
- Treating
manifest_hashas decoration. A self-receipt without the constitution's hash is an ungoverned action;verify_governanceflags it. It is the whole mechanism. - Passing a workload token that does not name the DID. The bridge refuses it; a captured SVID must not be pairable with an attacker-signed binding.
Agents and delegation done wrong
- Giving an agent the user's refresh token. Use token exchange: the agent's service
trades the user's access token (issued to it) for a short-lived token whose
actnames the agent. Nothing long-lived ever leaves the app. - Exchanging a token you were not issued.
invalid_grant. A subject token'saudmust be the requesting client; holding someone else's token is a leak, not a delegation. - Expecting a refresh token from exchange or client_credentials. Neither issues one.
- Polling the device endpoint faster than
interval.slow_down; back off. - Sending a DPoP-bound token as
Bearer. 401. UseAuthorization: DPoPwith a fresh proof carryingath. - Reusing a DPoP proof or a client_assertion. Their
jtis are single-use.
Step-up done wrong
- Reading
amrto decide if a session is strong. Useacr == "urn:zanii:mfa";amrlists methods and will grow (webauthntoday). - Sending
acr_valueson every login. Only on the routes that need it; users without a second factor get bounced, which is the point on a payout page and a bug on a landing page. - Handling
unmet_authentication_requirementsas a failure. It means "this user has no second factor yet". Link them to/ui/accountand retry afterwards. - Assuming the org key is a bearer for everything. It registers clients (
/register,/orgs/*) and nothing else; it is not an OAuth token.
Lifecycle events
- Ignoring
user.deleted. The user deleted their Zanii ID; your copy of their data is now yours alone to justify. Erase or anonymise it. The DPA draft makes this a contractual obligation for organizations. - Treating
consent.revokedas an error. It is the user's decision. Drop the local session; the next/authorizeshows the consent screen again. - Refreshing after
user.password_changed. The refresh family is already revoked; the next refresh returnsinvalid_grant. Send the user back through/authorize. - Verifying the lifecycle signature with a different secret. It is the same
webhook_secretas the relay, samesha256=scheme, same raw-bytes rule.
Relaying webhooks
The ledger notifies your product; your product relays to Zanii ID. Two things break it:
- Signing with the wrong secret. Use the
webhook_secretZanii ID issued you at client registration, not the ledger's webhook secret. - Re-serialising the body. Sign and send the exact bytes.
json.dumps(json.loads(x))changes the preimage and every delivery fails with 403.
You must also add the subject_tag you stamped — the ledger's envelope does not carry it,
and without it the event is ignored.
Logout
RP-initiated logout ends the Zanii ID session, not your product's. Destroy your own
session first, then redirect to /logout?id_token_hint=…&post_logout_redirect_uri=… with a
URI registered for the client. Skipping your own cleanup leaves the user logged in locally
while signed out centrally.
Testing
Point ZANII_ISSUER at a local identity server (http://localhost:8000) — the SDK config
allows localhost. Do not mock the token endpoint with hand-written JWTs unless you also
serve a JWKS: verification is real and will reject them, which is the point of it.
Subject tags for the IdP's own events derive from the issuer, so development and production produce different tags. A receipt stamped locally will not appear in a production user's feed.
Talking about it accurately
- Zanii ID proves who signed in. It is authentication, not identity verification — it never establishes that a person is who they claim to be offline.
- A verified email means control of an inbox was demonstrated, nothing more.
- Zanii ID holds custodial keys for user DIDs. Say "an audit handle", never "a wallet": the DID deliberately carries no authority and no value.
- Never say Zanii verified what it only recorded. "Domain verified" means the
organization's key and its domain agree on who it is, not that Zanii vetted the company.
An agent's
agentclaim is what its operator declares is running —declared_by: operator— and nobody can attest that remotely. A task grant'sceilingis recorded and proven, enforced by the resource server. Name the party who asserted a fact, and for a limit, name the party who enforces it. - Organization membership says the person belongs to the organization behind the client asking, and their role. It never lists their other memberships, which would link a person across companies.