Custom JWT Claims
Add custom fields to HelloJohn access tokens using metadata and hooks.
Custom JWT Claims
HelloJohn access tokens include standard claims and HelloJohn-specific fields. You can extend them with your own application data — eliminating extra database lookups on every request.
Default Claims
Every HelloJohn access token includes:
{
"sub": "usr_01HABCDEF123456",
"session_id": "ses_01HABCDEF999888",
"tenant_id": "tnt_01HABCDEF654321",
"org_id": "org_01HABCDEF777666",
"role": "member",
"mfa_verified": true,
"email": "alice@example.com",
"iat": 1705315800,
"exp": 1705319400,
"iss": "https://api.hellojohn.dev",
"aud": "tnt_01HABCDEF654321"
}Adding Custom Claims via Metadata
The simplest way to add custom claims is via public_metadata. Fields in public_metadata are automatically included in the JWT:
# Set metadata on the user
curl -X PATCH "https://api.hellojohn.dev/v1/users/usr_01HABCDEF123456" \
-H "Authorization: Bearer sk_live_abc123" \
-H "X-Tenant-ID: tnt_01HABCDEF654321" \
-H "Content-Type: application/json" \
-d '{
"public_metadata": {
"plan": "pro",
"seat_id": 42,
"feature_flags": ["new_editor", "beta_ui"]
}
}'The resulting JWT:
{
"sub": "usr_01HABCDEF123456",
"role": "member",
"public_metadata": {
"plan": "pro",
"seat_id": 42,
"feature_flags": ["new_editor", "beta_ui"]
}
}Your backend reads these claims directly from the JWT — no database call needed:
function requirePlan(plan: string) {
return (req: Request, res: Response, next: NextFunction) => {
const userPlan = req.token.public_metadata?.plan ?? "free";
const hierarchy = ["free", "starter", "pro", "enterprise"];
if (hierarchy.indexOf(userPlan) < hierarchy.indexOf(plan)) {
return res.status(403).json({
error: "upgrade_required",
message: `This feature requires the ${plan} plan.`,
});
}
next();
};
}
app.get("/export/csv", requirePlan("pro"), handleExport);Adding Custom Claims via Hooks (Pro+)
For dynamic claims computed at token issuance time, use a JWT Hook — an HTTP endpoint HelloJohn calls when issuing tokens.
How Hooks Work
User signs in
→ HelloJohn issues token
→ HelloJohn calls your hook: POST https://your-app.com/hooks/jwt
→ Your hook returns extra claims
→ Claims are merged into the final JWTSetting Up a JWT Hook
curl -X PATCH "https://api.hellojohn.dev/v1/admin/config" \
-H "Authorization: Bearer sk_live_abc123" \
-H "X-Tenant-ID: tnt_01HABCDEF654321" \
-H "Content-Type: application/json" \
-d '{"hooks": {"jwt": "https://your-app.com/hooks/jwt"}}'Hook Payload (Request)
HelloJohn calls your endpoint with:
{
"user": {
"id": "usr_01HABCDEF123456",
"email": "alice@example.com",
"role": "member",
"public_metadata": {}
},
"session": {
"id": "ses_01HABCDEF999888",
"org_id": "org_01HABCDEF777666"
},
"tenant_id": "tnt_01HABCDEF654321"
}Hook Response
Return extra claims to include in the JWT:
{
"claims": {
"plan": "pro",
"team_id": "team_42",
"permissions": ["export:csv", "api:write"]
}
}Hook Implementation (Node.js)
import express from "express";
const app = express();
app.post("/hooks/jwt", express.json(), async (req, res) => {
// Verify the request comes from HelloJohn
const signature = req.headers["hellojohn-hook-signature"] as string;
if (!verifyHookSignature(req.rawBody, signature, process.env.HOOK_SECRET!)) {
return res.status(401).json({ error: "invalid_signature" });
}
const { user } = req.body;
// Look up data from your database
const profile = await db.profiles.findBy({ hj_user_id: user.id });
res.json({
claims: {
plan: profile?.plan ?? "free",
team_id: profile?.team_id,
onboarding_complete: profile?.onboarding_complete ?? false,
},
});
});Hook Timeouts
Your hook must respond within 3 seconds. If it times out or returns an error:
- HelloJohn issues the token without custom claims
- An error is logged in the audit log
Accessing Custom Claims
Backend (JWT verification)
const payload = await verifyAccessToken(token);
const plan = payload.public_metadata?.plan ?? payload.plan ?? "free";
const teamId = payload.team_id;
const permissions: string[] = payload.permissions ?? [];Frontend (SDK)
import { useUser } from "@hellojohn/react";
function PlanBadge() {
const { user } = useUser();
return <span>{user.public_metadata?.plan ?? "free"}</span>;
}Limits
| Limit | Value |
|---|---|
| Max custom claims size | 8 KB |
| Hook response timeout | 3 seconds |
| Hook retries | 0 (no retries) |
Avoid including large arrays or nested objects — keep claims small and focused.