Lesson 2 · Authentication — who are you?

The JWT & its claims

A signed token is the whole identity — and one field inside it decides which tenant's data you'll ever see.

Your win: explain what a JWT is made of, read the Manabie claim this repo puts inside its tokens, and name the single field that drives multi-tenant isolation everywhere downstream.

What a JWT is

A JSON Web Token ([RFC 7519]) is three Base64URL parts joined by dots: header.payload.signature.1 The header names the algorithm (here RS256); the payload is a set of claims (JSON key/values — who, which tenant, when it expires); the signature is the header+payload signed with a private key. Anyone with the matching public key can verify the signature — so a JWT is tamper-evident but not secret (the payload is readable by anyone; never put a password in it).

eyJhbGciOiJSUzI1Ni... . eyJzdWIiOiJ1c2VyLTE... . Q1f8s...signature... └── header (alg RS256) ──┘ └──── payload (claims) ───┘ └── RSA signature ──┘ readable readable verifiable only

The claims struct — a token of many faces

This repo's token can carry several claim blocks at once. The Go struct that models them:

internal/golibs/interceptors/auth_claims.go:13
type CustomClaims struct {
    *jwt.Claims                    // standard: sub, iss, aud, exp, iat
    *FirebaseClaims                // email, firebase.tenant (external login)
    Hasura   *HasuraClaims         // x-hasura-* (for Hasura's own RLS)
    Manabie  *ManabieClaims        // ← the one that matters here
    *Auth0Claims                   // org_id (Auth0 logins)
    // …Salesforce, JwkURL…
}

The *jwt.Claims part is the standard set every JWT has: sub (subject = the user id), iss (issuer), aud (audience), exp (expiry). Those are what the verifier checks in Lesson 4. The identity your code reads lives in the Manabie claim.

The Manabie claim — identity + tenant

internal/golibs/interceptors/auth_claims.go:101
type ManabieClaims struct {
    UserID       string   `json:"user_id"`
    UserGroup    string   `json:"user_group"`       // the caller's primary role/group
    AllowedRoles []string `json:"allowed_roles"`   // roles they may act as
    DefaultRole  string   `json:"default_role"`
    SchoolIDs    []string `json:"school_ids"`
    ResourcePath string   `json:"resource_path"`   // ← the ORGANIZATION / TENANT id
    TenantID     string   `json:"tenant_id"`
}
The most important field in the whole course ResourcePath is the organization (tenant) id. It travels inside the signed token, so it cannot be forged or overridden by the request body. Downstream it becomes a Postgres session variable and the key that every row-level-security policy checks (Part 3). Almost all of this course's tenant isolation traces back to this one claim field. The golden rule (from .claude/rules/security.md): never take resource_path from the request — only from the JWT.

The Hasura twin

Look back at the struct: there's also a Hasura claim. It holds the same values as Manabie but with an x-hasura- prefix — the code comment says it outright:

internal/golibs/interceptors/auth_claims.go:91,100
type HasuraClaims struct {                 // "ManabieClaims has exact value
    UserID       string `json:"x-hasura-user-id"`         //  as HasuraClaims but without prefix"
    ResourcePath string `json:"x-hasura-resource-path"`
    UserGroup    string `json:"x-hasura-user-group"`
    // …
}

Why two copies? Because Hasura (the GraphQL layer) reads the x-hasura-* claims to drive its own row-level security, using the same resource_path and user_id. So one minted token carries identity in two dialects: manabie.* for the Go services, x-hasura-* for Hasura. Same facts, two readers.

Where these claims come from and go Minted by Shamir when you exchange a login token (Lesson 3). Verified and unpacked onto the request context by the auth interceptor (Lesson 4), which is how UserIDFromContext(ctx) and ResourcePathFromContext(ctx) work (auth.go:354,466). This lesson is the token at rest; the next two are where it's born and where it's checked.
Read this next

JWT (RFC 7519) & custom claims for authorization

The token format itself, and Google's model for putting roles/tenant into a token's claims — exactly what the Manabie claim does.

RFC 7519 — JSON Web Token
Firebase — custom claims & authorization · Auth0 — what's in a token

Check yourself (from memory)

Q1. What are the three dot-separated parts of a JWT?

header.payload.signature. The payload holds the claims and is readable by anyone — only the signature is verifiable-only.

Q2. Which Manabie claim field drives multi-tenant isolation downstream?

ResourcePath = the org. It becomes a Postgres session var and is checked by every tenant RLS policy. Never taken from the request.

Q3. Why does one minted token carry both manabie.* and x-hasura-* claims?

Same facts, two readers: manabie.* for the Go services, x-hasura-* for Hasura's own row-level security.
Recall: JWT structure + what the Manabie claim carries.
structure + the key fields, then reveal
JWT = header.payload.signature (Base64URL, dot-joined, RFC 7519). Header = alg (RS256); payload = claims (readable, not secret); signature = RSA-signed, verifiable with the public key. Standard claims: sub/iss/aud/exp. ManabieClaims (auth_claims.go:101): user_id, user_group, allowed_roles[], default_role, school_ids[], resource_path (= the ORG/TENANT id — drives all RLS; never from the request), tenant_id. Same values also minted as x-hasura-* (HasuraClaims) so Hasura can run its own RLS. Read onto ctx by the interceptor (UserIDFromContext, ResourcePathFromContext).
🎯 Interview one-liner "What's inside your auth token?" → "A JWT — header, claims, signature. Our own claim block carries the user id, their roles, and crucially the resource_path, which is the tenant. Because it's inside the signed payload it can't be spoofed, and everything downstream — right down to Postgres row-level security — trusts it for tenant isolation. We never read the tenant from the request body."
Next: Shamir, the service that mints this token — how a Firebase login becomes a Manabie JWT stamped with your roles and tenant, and the JWKS every service trusts. Ask me if the claims model needs another pass.

1. RFC 7519 (JWT), Firebase custom claims. In-repo: internal/golibs/interceptors/auth_claims.go:13,91,101.