Auth hooks
Add your own claims to Latchkey tokens at sign-in — your backend stays the source of truth for who can do what inside your product. If you know Supabase's auth hooks, this is the same idea.
Why
Your product is multi-tenant inside itself — homes, properties, workspaces. Your GraphQL gateway authorizes mutations from the token's ns claims, but Latchkey shouldn't have to mirror your tenancy tables to put them there. An auth hook inverts it: at every token mint and refresh, Latchkey calls your endpoint and merges what you return.
Tokens are also scoped per organization: a client tagged to your org receives only your org's claims — never another tenant's world.
The request
Latchkey POSTs JSON to your configured endpoint:
POST /your/hook HTTP/1.1
Content-Type: application/json
X-Latchkey-Signature: 3f9a… (HMAC-SHA256 of the raw body, hex)
{
"sub": "9d3e…-…", // the user's identity id — your foreign key
"email": "sam@example.com", // when they signed in by email
"phone": "+61400000123", // when they signed in by phone
"client_id": "1aa9…-…",
"org": "grapevine" // your org slug
}
Verify the signature with the secret you generated in the console (constant-time compare):
// Node / TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
const want = createHmac("sha256", process.env.LATCHKEY_HOOK_SECRET!)
.update(rawBody).digest("hex");
const got = req.headers["x-latchkey-signature"] ?? "";
if (got.length !== want.length ||
!timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
return res.status(401).end();
}
The response
Answer within 2 seconds with the claims to merge:
{
"claims": {
"grapevine/home-123": "member",
"grapevine/home-980": "owner",
"user_role": "admin",
"app_metadata": { "plan": "pro" }
}
}
Keys route to one of two places. Keys prefixed {org}/ are tenant memberships and merge into the token's ns claim. Every other key is yours — it lands top-level in the access token, with any JSON value.
| Rule | Detail |
|---|---|
| Tenant claims | {org}/-prefixed keys merge into ns with role owner, member or viewer. Other orgs' keys, the bare org slug and wildcards are dropped — org-level roles are managed in Latchkey itself, so a hook can never mint an org owner. |
| Custom claims | Any other key passes through top-level, any JSON value. Reserved names are dropped: the protocol claims (iss, sub, aud, exp, iat, sid, nonce, scope), the identity claims Latchkey answers for (email, phone_number, name), and ns / role. |
| Freshness | The hook runs on every mint and every refresh — permission changes in your database reach tokens at the next refresh, no re-login. |
| Failure | Degrades, never blocks: if your hook is down or slow, sign-in continues with the claims Latchkey has stored (custom top-level claims are simply absent until it recovers). Your outage can't take sign-in down. |
What lands in the token
{
"sub": "9d3e…-…",
"email": "sam@example.com",
"name": "Sam Doe",
"role": "authenticated",
"ns": {
"grapevine/home-123": "member", // ← your hook
"grapevine/home-980": "owner" // ← your hook
},
"user_role": "admin", // ← your hook
"app_metadata": { "plan": "pro" }, // ← your hook
"aud": "latchkey", "iss": "https://auth.latchkey.id", …
}
Your gateway maps ns entries to access exactly as it would any membership claim; custom claims are consumed by your own systems — for example, row-level-security policies reading request.jwt.claims when the token is bridged into Supabase. role is always authenticated on a signed-in user's access token — it is what Supabase third-party auth checks for. Custom claims ride on the access token (the one your backend and database see), matching Supabase's custom access token hook.
No backend yet? Stored grants
If you'd rather push than be called, your org's confidential client can write tenant claims into Latchkey directly with its client_credentials token:
POST https://auth.latchkey.id/org/{org}/grants
Authorization: Bearer <client_credentials token>
{ "identity_id": "9d3e…-…", "namespace": "grapevine/home-123", "role": "member" }
Stored grants and the hook compose — the hook wins on conflicts, and stored grants keep working while your hook is down.
Setup
- Console → your org → Settings → Token enrichment: enter your endpoint URL, generate a signing secret, copy it to your backend, save. The secret is write-only — it never appears again.
- Ask Latchkey staff to tag your clients to your org — the tag turns on per-org scoping and your hook.
- Watch Recent calls in the same Settings section: timing, claim counts and errors for every hook call, live.
staff claim for platform staff. The contract documented here is the one we run in production.