Skip to navigation

Identity Exchange with Clerk Organizations

This guide connects a Clerk application that uses Organizations to Runtype Identity Exchange. When you finish, each request carries a verified end user (the Clerk user) and a verified tenant (the Clerk organization), and an agent can isolate data by organization and pass the organization to your tools.

For how Identity Exchange works with any OpenID Connect (OIDC) provider, see End-user identity and credentials.

Prerequisites

You need the following:

  • A Clerk application with Organizations enabled.
  • A Runtype API key with the INTEGRATIONS:WRITE permission.

Create a Clerk JWT template

Runtype requires every integration to name a specific audience, and it rejects a token whose aud claim does not match. A default Clerk session token has no aud claim, so create a JWT template that adds one.

To create the template, complete the following steps:

  1. In the Clerk Dashboard, open the JWT templates page and create a blank template named runtype.

  2. Set the template claims to the following JSON:

    Clerk JWT template claims
    {
    "aud": "YOUR_RUNTYPE_AUDIENCE",
    "org_id": "{{org.id}}",
    "email": "{{user.primary_email_address}}"
    }

    Replace YOUR_RUNTYPE_AUDIENCE with a name for the Runtype relying party, such as runtype-storefront. Use the same value as audience when you register the integration.

  3. Set the token lifetime to 60 seconds. Runtype rejects a token issued more than 10 minutes ago, with 30 seconds of clock skew allowed.

Clerk sets the azp claim to the origin of the page that requested the token. You list those origins in authorizedParties when you register the integration.

Register the integration

Register one integration for your Clerk instance. Runtype selects the integration by the token’s verified issuer, so create only one active integration for each Clerk instance.

The following request registers a Clerk integration:

cURL
curl https://api.runtype.com/v1/identity-integrations \
-H "Authorization: Bearer rt_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Storefront Clerk",
"provider": "clerk",
"descriptor": {
"kind": "oidc-jwt",
"issuer": "https://YOUR_CLERK_FRONTEND_API",
"jwksUri": "https://YOUR_CLERK_FRONTEND_API/.well-known/jwks.json",
"audience": "YOUR_RUNTYPE_AUDIENCE",
"allowedAlgorithms": ["RS256"],
"authorizedParties": ["https://shop.example.com"],
"claimMap": {
"subject": "sub",
"tenant": "org_id",
"email": "email"
},
"claimPolicy": {
"rejectImpersonation": true,
"rejectPendingOrg": true
}
}
}'

Replace the following placeholders:

  • rt_live_YOUR_API_KEY: your live Runtype API key.
  • YOUR_CLERK_FRONTEND_API: the host of your Clerk Frontend API URL, for example your-app.clerk.accounts.dev for a development instance.
  • YOUR_RUNTYPE_AUDIENCE: the aud value from your JWT template.
  • https://shop.example.com: every origin that requests tokens for Runtype.

The response contains the integration id, which starts with idint_. Runtype uses this ID as the isolation namespace for identities from this integration.

The following table explains the Clerk-specific settings:

FieldWhy to set it
audienceRequired. Wildcards and empty values are rejected. Clerk adds aud only through a JWT template.
allowedAlgorithmsClerk signs tokens with RS256.
authorizedPartiesRuntype rejects a token whose azp claim is not in this list. Set it so that a token minted for another origin on the same Clerk instance cannot be replayed to Runtype.
claimMap.tenantMaps the Clerk organization to the verified tenant. Without it, a proof carries no tenant, and a tenant-isolated agent rejects the request.
claimPolicy.rejectImpersonationRejects a token that has an act claim, which Clerk adds when an administrator impersonates a user.
claimPolicy.rejectPendingOrgRejects a token whose sts claim is pending, which Clerk sets while the session has an unfinished task, such as choosing an organization.

claimMap paths use dot notation, so a claim nested in an object, such as o.id, also works.

Send a proof on each request

Mint a fresh token for every request, and send it as identityProof. Runtype verifies the proof on each request and does not store it. Set provider to clerk. If you send a provider value, it must match the provider of the integration that the issuer selects, or Runtype rejects the proof with provider_hint_mismatch.

In the browser, request a token from the JWT template:

Browser
const token = await clerk.session?.getToken({ template: 'runtype' })

Send the token as a bearer token to your backend. Never send the Clerk __session cookie to Runtype.

Your backend attaches the token to a dispatch request:

cURL
curl https://api.runtype.com/v1/dispatch \
-H "Authorization: Bearer rt_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent": { "agentId": "agent_YOUR_AGENT_ID" },
"messages": [{ "role": "user", "content": "Show my open orders" }],
"identityProof": {
"provider": "clerk",
"token": "YOUR_CLERK_TOKEN"
}
}'

Replace agent_YOUR_AGENT_ID with the saved agent ID and YOUR_CLERK_TOKEN with the token from getToken. Asynchronous dispatch (Prefer: respond-async) does not accept identityProof and returns 400 ASYNC_IDENTITY_PROOF_UNSUPPORTED.

For an embedded chat widget, pass the same object as identityProof to POST /v1/client/init and POST /v1/client/chat. The Persona widget (version 4.24.0 or later) does this for you when you set identityProvider and getIdentityProof:

Persona widget config
config: {
clientToken: 'YOUR_CLIENT_TOKEN',
identityProvider: 'clerk',
getIdentityProof: async () =>
(await clerk.session?.getToken({ template: 'runtype' })) ?? null,
}

The widget calls getIdentityProof before every chat request and sends the token as identityProof: { provider: 'clerk', token }. If the function returns null, the widget stops the turn. Without identityProvider, the widget sends the proof only when it starts a session, so an agent with the tenant-isolated Tenancy Strategy rejects its chat requests with tenant_scope_required. For widget setup and conversation history, see Conversation history in an embedded widget.

Scope the agent to the organization

A verified proof sets _tenant and _endUser for the execution. To isolate data by Clerk organization, save the agent with the tenant-isolated Tenancy Strategy:

Agent config
{
"config": {
"tenancyStrategy": {
"preset": "tenant-isolated"
}
}
}

To send the organization to your API or MCP server, reference {{_tenant.id}} in a tool header. Runtype fills it from the verified proof, and the model cannot set it. To let your API verify the identity itself, send {{_identity.token}} instead. For the rules, see Tool template variables. To choose between organization and per-user isolation, see Tenancy strategies.

Verify the setup

Check that Runtype admitted the proof:

  • Widget. The POST /v1/client/init response carries visitor.identityStatus. The value admitted means that Runtype verified the proof. not_provided means that the request had no proof. ignored means that the request had a proof but Identity Exchange admission is not enabled for your organization.
  • Conversation history routes. The X-History-Identity-Status response header carries the same values.
  • Tools. A tool that sends {{_tenant.id}} receives the Clerk organization ID, such as org_2abc.

Troubleshooting

Runtype returns 401 with error: invalid_identity_proof when it rejects a proof. The errorDescription field names the reason:

ReasonLikely cause
no_proof_matchThe token’s iss does not match an active integration. Check issuer against the Clerk Frontend API URL.
verification_failedThe signature, aud, azp, expiry, or a claim policy failed. Check that the client used the runtype template and a new token.
provider_hint_mismatchThe provider value in identityProof differs from the integration’s provider.
malformed_proofThe token is not a JWT. Check that you sent the token string and not the session cookie.
unsupported_algorithmThe token’s signing algorithm is not in allowedAlgorithms.

Runtype returns 403 with error: Tenancy lock-down when a proof verifies but the agent’s Tenancy Strategy rejects the scope:

  • tenant_scope_required: the token has no org_id. The user has no active organization, or the integration has no claimMap.tenant.
  • tenant_assurance_below_floor: the tenant is not verified. This happens when the request carries a tenant but no admitted proof.

Next steps