What Clerk supplies
One value:userId, the user_… id Clerk’s own webhooks name. That is the
external_id.
lib/clerk.ts
auth() reads the session Clerk’s middleware already verified. Decoding the
__session cookie yourself and signing its sub would be signing a value the
browser chose.
auth() needs clerkMiddleware() mounted, or it throws rather than
answering with a null userId. That is the right failure: “I could not tell”
must never be read as “signed out”.The server half
app/api/userkit-boot/route.ts
The client half
getState().verified is true only when the HMAC checked out. Signed in to
Clerk with verified: false means either the secret belongs to the other
environment or the message signed was not exactly the external_id.
Configuration
Clerk organizations are not the identity
orgId is a second dimension, and signing userId + orgId into one message
would make one person two contacts. UserKit’s own team object is the
customer, resolved per request from
X-Customer-Id — map Clerk’s organization onto that, not onto the
external_id.
What does not travel
A Clerk user carries verified email addresses. They do not become identities in UserKit: an email sent through/v1/boot is stored as an attribute, does not
resolve to an existing contact and does not become an identity edge. The HMAC
proves the external_id and only the external_id.
To prove an address, send a magic link or an email
code — the two flows that arrive in it.
The full example
A runnable Next app with these files, an
.env.example and the pieces this page
leaves out.