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).
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:
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
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"`
}
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:
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.
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.
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?
manabie.* for the Go
services, x-hasura-* for Hasura's own row-level security.
Manabie claim carries.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).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."
1. RFC 7519 (JWT), Firebase custom claims. In-repo: internal/golibs/interceptors/auth_claims.go:13,91,101.