HelloJohn / docs
Authentication

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 JWT

Setting 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

LimitValue
Max custom claims size8 KB
Hook response timeout3 seconds
Hook retries0 (no retries)

Avoid including large arrays or nested objects — keep claims small and focused.


On this page