# Clerk

URL: https://docs.hanzo.ai/docs/guides/migrate/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.

```bash
# 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:

```bash
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": "dana@acme.com",
    "inviter_user_id": "user_2c7QwZ",
    "role": "org:member"
  }'
```

Hanzo:

```bash
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": "dana@acme.com",
    "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:

```bash
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 session
```

Hanzo:

```bash
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:

```bash
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.
