Clerk
Clerk is the drop-in auth layer for a React app — components, users, orgs, sessions, JWT. Here that is /v1/iam — 159 operations, OIDC end to end, and the org is the owner half of every address rather than a claim your frontend switches into.
Clerk authenticates people, groups them into organizations, and gives you React
components that render the whole flow. /v1/iam (159 operations) does the
identity half at the addresses OIDC names them, and serves the login screen's
contents rather than shipping you a component to hold them.
The structural difference to know before you port anything: Clerk's
organization is a claim the browser switches into and your backend reads back
off the request. setActive({ organization }) changes it client-side, the
session token carries org_id, and every org-scoped Backend API call takes that
id in the path. Here the org is the owner half of the address, and on most
listing routes it is not a parameter at all — it comes from the credential you
authenticated with, so there is no id to pass, keep in step, or get wrong.
Start here
Mint a key, find the application people sign in to, then read the login screen it should draw.
# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer $HANZO_SESSION" \
-H 'Content-Type: application/json' \
-d '{"type":"secret"}'
# 2. list the applications in your org — each carries its own OAuth client id
curl -sS "https://hanzo.id/v1/iam/applications?owner=acme" \
-H "Authorization: Bearer $HANZO_API_KEY"
# 3. read what a login screen for one of them should render
curl -sS "https://hanzo.id/v1/iam/get-app-login?clientId=$CLIENT_ID&responseType=code" \
-H "Authorization: Bearer $HANZO_API_KEY"Step 3 is the <SignIn /> replacement: one application's branding plus every
sign-in method it has switched on, client secrets masked, served before anyone
has signed in. The only identifiers in play are acme and an OAuth client id —
no org_2xxx anywhere, and switching a provider on changes what step 3 returns
without a frontend release.
Core capabilities
| Capability | What it does | Operations |
|---|---|---|
/v1/iam | Users, orgs, applications, sessions, OIDC, MFA, SCIM — Clerk's Backend API, plus the contents its components used to hold | 159 |
/v1/webhook | Subscribe by subject pattern (commerce.order.>), delivered with retries; per-endpoint secret rotation, delivery log and test send | 8 |
/v1/account | The key you minted in step 1, the avatar, and the orgs one identity may act in | 10 |
Nouns
The tenant and the people in it
| Clerk | Hanzo |
|---|---|
| Instance (development, production) | Nothing to port. The org is the tenant |
| Application | Application — /v1/iam/applications, addressed {owner}/{name} |
User, user_2xxx | /v1/iam/users, addressed {owner}/{name} |
Organization, org_2xxx | /v1/iam/organizations, addressed {owner}/{name} |
| Organization membership and its role | POST /v1/iam/memberships, POST /v1/iam/delete-membership |
| Organization invitation | POST /v1/iam/invitations — org taken from your key |
org:admin, custom permissions | /v1/iam/roles, /v1/iam/permissions |
| Impersonation, actor tokens | POST /v1/iam/assume, then POST /v1/iam/release |
| Audit logs (Enterprise) | GET /v1/iam/audit-logs — and POST to write your own events into the same trail |
Signing in
| Clerk | Hanzo |
|---|---|
<SignIn />, <SignUp /> | GET /v1/iam/get-app-login — branding and every enabled method, from the server |
| Which factors are switched on | GET /v1/iam/auth/methods |
signIn.create, signUp.create | POST /v1/iam/login, POST /v1/iam/signup, then POST /v1/iam/signin |
| Hosted account portal | GET /v1/iam/oauth/authorize, hosted at hanzo.id |
| Session | /v1/iam/sessions, one per {owner}/{name}/{application} |
| Session token (JWT) | POST /v1/iam/oauth/token |
| Networkless verification | GET /v1/iam/.well-known/jwks |
| Is this token still good | POST /v1/iam/oauth/introspect — verification and revocation in one answer |
signOut() | POST /v1/iam/oauth/revoke, or /v1/iam/oauth/logout for the browser |
| Email code, SMS code | POST /v1/iam/send-verification-code |
| Passkeys | GET /v1/iam/webauthn/signin/begin, /v1/iam/webauthn-credentials |
| TOTP, backup codes | POST /v1/iam/mfa/setup/initiate, then /v1/iam/mfa/setup/enable |
| Web3 wallet sign-in | GET /v1/iam/web3/nonce, POST /v1/iam/web3/verify |
| Sign-in on a TV or a CLI | POST /v1/iam/oauth/device |
Around the edges
| Clerk | Hanzo |
|---|---|
<UserProfile /> | GET and PUT /v1/iam/account, POST /v1/account/avatar |
<OrganizationSwitcher /> | GET /v1/iam/memberships?user=acme/dana — the orgs one identity may act in |
| External accounts (social connections) | /v1/iam/linked-accounts, POST /v1/iam/link, POST /v1/iam/unlink |
| SAML connections, enterprise SSO | /v1/iam/providers — configured once, switched on per application |
| SCIM directory sync (Enterprise) | /v1/iam/scim/v2/Users — not a tier |
| Machines and M2M API keys | POST /v1/iam/keys, /v1/iam/service-accounts |
Publishable key, pk_live_… | GET /v1/iam/keys/org — resolves to an org and never to a person |
Webhooks (user.created, via Svix) | /v1/webhook (8 operations) |
| Bulk import for migration | POST /v1/iam/admin/users/upsert — idempotent, one person per call |
| Signing key rotation | /v1/iam/certs — stage the next kid before it signs |
The call
Clerk, inviting somebody into an organization:
curl -sS -X POST \
"https://api.clerk.com/v1/organizations/org_2b9KpQ/invitations" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{
"email_address": "[email protected]",
"inviter_user_id": "user_2c7QwZ",
"role": "org:member"
}'Hanzo:
curl -sS -X POST https://hanzo.id/v1/iam/invitations \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "dana",
"email": "[email protected]",
"application": "acme-web",
"signupGroup": "member",
"quota": 1
}'Two opaque identifiers left the request. The organization is not in the path
because the listing and the write both read it off the validated credential —
owner is an optional query parameter on /v1/iam/invitations, /v1/iam/roles,
/v1/iam/certs and /v1/iam/audit-logs, and leaving it out is the normal case.
The inviter is gone for the same reason: the credential already names who is
asking, so a second field naming them again could only ever disagree with it.
Support access is the sharper case. Clerk mints an actor token and then walks a browser through a ticket to start a session:
curl -sS -X POST https://api.clerk.com/v1/actor_tokens \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{"user_id":"user_2c7QwZ","actor":{"sub":"user_2aAdRn"}}'
# then send a browser to the returned ticket URL to open the sessionHanzo:
curl -sS -X POST https://hanzo.id/v1/iam/assume \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"org":"acme"}'
curl -sS -X POST https://hanzo.id/v1/iam/release \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"org":"acme"}'One call, no browser, and what comes back is the operator's own token re-scoped
to the tenant — it still names the operator and records the org it was scoped
to, so every write made with it is attributed to the person who made it rather
than to the customer. release hands back the credential they held before, so
stepping out is a call rather than waiting for something to expire. Only a
platform operator may step in, and the attempt is recorded whether or not it
succeeds.
Token checks work the same way in both places until revocation. Clerk verifies
the session JWT locally against a JWKS and bounds a revoked session by its
60-second lifetime — you wait for it. The same offline verification is here at
GET /v1/iam/.well-known/jwks, and when you need the answer now:
curl -sS -X POST https://hanzo.id/v1/iam/oauth/introspect \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "token=$ACCESS_TOKEN"A token is active only if it verifies and has not been revoked, so a revoked token reads dead on the next call rather than at the end of its window. That is also why refresh is rotating: a refresh returns a new refresh token and retires the one you sent, and presenting a retired one revokes the whole chain on the assumption it was copied.
What does not carry
The rendered components do not port. <SignIn />, <UserButton /> and
clerkMiddleware() are Clerk's product — nothing here draws a screen for you.
The React around them does port: @hanzo/iam/react publishes IamProvider in
place of <ClerkProvider>, plus useIam and useOrganizations, so it is an
OIDC client with React bindings rather than a component library. What replaces
the screens is the other half of the same problem: GET /v1/iam/get-app-login
returns one application's branding and every sign-in method it has switched on,
with client secrets masked, so a screen you draw yourself grows a new provider
button the moment somebody enables one. The hosted alternative is hanzo.id.
No JWT templates. Clerk mints a differently-shaped token per integration —
Supabase, Firebase, Hasura — from a template you edit in the dashboard. Tokens
here carry the standard OIDC claims plus owner, signed by a certificate you
register at /v1/iam/certs. A third party that needs a bespoke claim shape gets
it from a service of yours that has already verified the real token.
The three metadata buckets collapse to one. publicMetadata,
privateMetadata and unsafeMetadata differ by who may read and who may write
them. The user record here has properties, tag and externalId, and they
are read through the same admin-scoped user routes as everything else — there is
no bag the browser reads directly and no bag the user writes. Settings a person
owns live at /v1/iam/preferences, and their privacy answers at
/v1/iam/consent; both are writable only by that person. Application state about
a user belongs in your store, keyed on {owner}/{name}.
user_2xxx and org_2xxx do not survive the move. The address here is
{owner}/{name} — a username inside an org — and there is no route that accepts
a Clerk id. Carry the old id in externalId on the user record and rewrite your
foreign keys once, rather than maintaining a lookup table forever.
Password digests carry only if they are bcrypt or argon2id. The user record
holds passwordHash, passwordSalt and passwordType, and the scheme field is
what lets a legacy argon2id row still verify and be re-hashed to bcrypt on the
next sign-in. Clerk's other importable hashers have no verifier here. Export the
users, and for any other scheme move those people onto a provider or a password
reset.
Bot protection is not a switch on the sign-up form. Clerk fronts sign-up
with Smart CAPTCHA. /v1/iam models no CAPTCHA provider and
POST /v1/iam/send-verification-code accepts captchaType and ignores it. Rate
limiting and origin rules are /v1/gateway/config, and the abuse score is
/v1/risk/score — separate capabilities with their own vocabulary, so this is a
thing to configure rather than a checkbox you tick back on.
How is this guide?
Auth0
Auth0 is the identity provider your app redirects to. Here that is /v1/iam — 159 operations, OIDC end to end, and the org is a first-class noun rather than an add-on.
WorkOS
WorkOS sells enterprise readiness as four products — SSO, SCIM, audit logs, an admin portal — wired to one organization. Here they are one capability, /v1/iam (159 operations), addressing one user record.