zaniiid

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; sub is the immutable zanii_user_id. Key on email and a user who changes it becomes a second account, while two products disagree about who they are. Upsert on sub, 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 email or offline_access and still press Allow. Read the token response's scope. Code that does tokens["refresh_token"] unconditionally now raises a KeyError the first time someone declines to stay signed in, and code that reads claims["email"] breaks the same way. Branch on what you actually received.

  • Skipping PKCE because the client is confidential. Zanii ID requires code_challenge_method=S256 from every client. "We hold a secret, so we don't need PKCE" is true of plain OAuth 2.0 and false here — /authorize rejects 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 /token in a retry decorator.

  • Retrying, or swallowing, invalid_grant on 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 /authorize again.

  • 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/react throws on any prop matching secret|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_token signature against the JWKS plus iss, aud, exp and nonce. If you hand-roll, pin alg to RS256 from discovery — reading alg from 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 state after 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/sdk is identity; @zanii/subject, @zanii/core and the rest are the accountability ledger. They interoperate, but @zanii/id is the old name and no longer correct.

  • Calling /userinfo on 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, http vs https, or a different port is a different URI, and /authorize refuses 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' sub is pairwise. It is stable for your organization but is not the zanii_user_id, and you cannot compare it with another company's sub for the same person. That is the point. Key your users on it exactly as you would the global id.
  • You will not receive a did claim, 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 ceiling as 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_left as 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. Size max_uses accordingly and say what it means in your own interface.
  • Asking for a task grant with three of the four parameters. purpose, max_uses and grant_ttl are all required together (ceiling is optional); a partial request is invalid_request, deliberately, because half a description is not something a person can meaningfully approve.
  • Retrying past a spent grant. invalid_grant naming a spent, expired or revoked task grant is final. Ask the user for a new one; do not loop.

Approvals done wrong

  • 🔴 Reading expired as 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 on POST /approvals; the client authenticates in the body. Sending Basic auth there silently replaces the user token and you get invalid_token.
  • Polling faster than interval. You get slow_down, exactly as in the device flow.

Resource indicators and MCP

  • Assuming aud is your client_id. With resource= it is the resource identifier, and client_id is a separate claim. A resource server should check aud equals 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 resource you never registered. It is invalid_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 did claim 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/health episode 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_hash as decoration. A self-receipt without the constitution's hash is an ungoverned action; verify_governance flags 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 act names the agent. Nothing long-lived ever leaves the app.
  • Exchanging a token you were not issued. invalid_grant. A subject token's aud must 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. Use Authorization: DPoP with a fresh proof carrying ath.
  • Reusing a DPoP proof or a client_assertion. Their jtis are single-use.

Step-up done wrong

  • Reading amr to decide if a session is strong. Use acr == "urn:zanii:mfa"; amr lists methods and will grow (webauthn today).
  • Sending acr_values on 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_requirements as a failure. It means "this user has no second factor yet". Link them to /ui/account and 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.revoked as an error. It is the user's decision. Drop the local session; the next /authorize shows the consent screen again.
  • Refreshing after user.password_changed. The refresh family is already revoked; the next refresh returns invalid_grant. Send the user back through /authorize.
  • Verifying the lifecycle signature with a different secret. It is the same webhook_secret as the relay, same sha256= scheme, same raw-bytes rule.

Relaying webhooks

The ledger notifies your product; your product relays to Zanii ID. Two things break it:

  1. Signing with the wrong secret. Use the webhook_secret Zanii ID issued you at client registration, not the ledger's webhook secret.
  2. 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 agent claim is what its operator declares is running — declared_by: operator — and nobody can attest that remotely. A task grant's ceiling is 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.