Lesson 4 · Authentication — who are you?
The auth interceptor
The checkpoint on every RPC: pull the token, verify it against the key set, and stamp the caller's identity onto the request context.
Your win: trace what the auth interceptor does on every request, explain how a token is verified against the JWKS, and describe the two "skip" lists — one that bypasses auth entirely, one that fakes a JWT for service-to-service calls.
One gate, every request
The token from Lesson 3 is presented in gRPC metadata under the key token. A single
unary interceptor — early in every service's interceptor chain — checks it before
any handler runs. This is gate ① of the map:
func (a *Auth) UnaryServerInterceptor(ctx, req, info, handler) (interface{}, error) {
if a.skipAuthMethods[info.FullMethod] { // ignoreAuthEndpoint → bypass entirely
return handler(ctx, req)
}
claims, err := a.verify(ctx) // ① read + verify the JWT
if err != nil { return nil, err } // bad token → Unauthenticated
ctx = ContextWithUserID(ctx, claims.Subject) // ② stamp identity onto ctx
if claims.Manabie != nil && claims.Manabie.UserGroup != "" {
ctx = ContextWithUserGroup(ctx, claims.Manabie.UserGroup)
}
ctx = ContextWithJWTClaims(ctx, claims) // (resource_path now reachable)
groups, err := a.groupDecider.Check(ctx, claims.Subject, info.FullMethod) // ③ the role gate → Lesson 5
if err != nil { return nil, err }
if len(groups) > 0 {
ctx = ContextWithUserGroup(ctx, groups[0]); ctx = ContextWithUserRoles(ctx, groups)
}
return handler(ctx, req) // ④ pass to the handler, identity in hand
}
Two things happen here that the rest of the codebase depends on. First, a bad or missing token is
rejected right here (codes.Unauthenticated) — no handler ever sees it. Second, the
verified claims are put on the context, which is how every downstream layer reads
identity: UserIDFromContext(ctx), ResourcePathFromContext(ctx),
UserGroupFromContext(ctx). That context is also what the database pool reads to set the
RLS tenant variable (Part 3). This interceptor is where the JWT becomes the request's identity.
(Step ③, the role gate, also lives here — but that's authorization, so we unpack it in Lesson 5.)
Verify: signature, issuer, audience, expiry
verify pulls the token from metadata and runs it through a TokenVerifier
that fetched Shamir's JWKS (Lesson 3):
parse the signed JWT → find the matching public key in the cached JWKS
verify the RS256 signature
check Issuer, Audience, and Time (expiry) // jwt.Expected{...}
// on a key miss: re-fetch the JWKS once, then retry (keys rotate)
Those four checks — signature, issuer, audience, expiry — are the canonical JWT
validation steps.1 The service holds multiple
verifiers (one per configured issuer: Manabie, Firebase, per-org projects); verify tries
each and the first success wins. That's how one gate accepts both freshly-minted Manabie tokens and
federated ones.
internal/usermgmt/pkg/interceptors. There's a
look-alike Auth in internal/golibs/interceptors whose
GroupDecider.Check returns a single string instead of []string
— the legacy version. Services wire the usermgmt one; the golibs package survives
because it owns the shared TokenVerifier, CustomClaims, and the context
accessors both use. Grep with the package in mind.
The two skip lists
Not every endpoint can (or should) require a token. Two per-service lists handle the exceptions — and they mean very different things:
ignoreAuthEndpoint
Fills skipAuthMethods → these methods bypass JWT verification entirely.
Strictly for things unauthenticated by design: health checks, ExchangeToken (you
don't have a Manabie token yet!), GetOAuthUrl, ResetPassword.
fakeJwtCtxEndpoint
Drives a fake-JWT interceptor that synthesizes claims for internal
methods — ResourcePath = req.GetOrganizationId(), a School-Admin group — instead of
verifying a real token. This is service-to-service auth for calls that originate
from NATS/Kafka, not a human.
ignoreAuthEndpoint means "no identity — truly public." fakeJwtCtxEndpoint
means "a synthesized identity — trusted because it came from inside the mesh, scoped to the
org in the request." They're often paired on the same internal method (bypass the real-token
requirement, then fabricate a service identity). Confusing them is a security bug: a business
endpoint in ignoreAuthEndpoint would be wide open. The rule (security.md):
only genuinely-unauthenticated endpoints belong in the ignore list.2
Validating a token: signature, issuer, audience, expiry
The canonical checks verify performs, from an authority that does nothing else.
→ Curity — validating an ID token
→ Firebase — verify ID tokens (JWKS, RS256) ·
RFC 7519 §4 — registered claims
Check yourself (from memory)
Q1. After the interceptor verifies a token, how do downstream layers read the caller's identity?
ctx; everything downstream reads …FromContext(ctx).
Q2. Which four checks make up JWT verification here?
Q3. How does fakeJwtCtxEndpoint differ from ignoreAuthEndpoint?
ignoreAuthEndpoint = truly public (no identity).
fakeJwtCtxEndpoint = fabricated School-Admin claims from the request's org, for
trusted service-to-service calls.
usermgmt/interceptors/auth.go:79):
if ignoreAuthEndpoint → bypass; else verify(ctx) reads
md.Get("token") and checks it via TokenVerifier (JWKS: RS256 signature +
issuer + audience + expiry; multiple issuers, first wins; re-fetch JWKS once on key miss). Then
stamp ctx: ContextWithUserID(claims.Subject) + UserGroup +
JWTClaims → run the role gate (groupDecider.Check, Lesson 5) → set
UserRoles(groups) → call handler. Bad/missing token → Unauthenticated.
Two skip lists: ignoreAuthEndpoint = bypass entirely (health,
ExchangeToken, GetOAuthUrl, ResetPassword — no identity); fakeJwtCtxEndpoint =
synthesize School-Admin claims from req.OrganizationId (service-to-service). Live
interceptor = usermgmt package (not the golibs look-alike).Manabie claim (L2), Shamir minting and exchanging it and hosting the
JWKS (L3), and the interceptor verifying it on every request and putting identity
on the context (L4) — all on the map from L1. The door is covered. Next: what happens
inside.
rbacDecider, and a myth
in the docs worth correcting), granular permissions, and the location tree. Tell me
"build Part 2" when you're ready, or ask me anything about authentication first.
Re-take these four quizzes tomorrow cold — spacing is what makes it stick.
1. Curity — validating a token, Firebase verify. In-repo: internal/usermgmt/pkg/interceptors/auth.go:79,120, internal/golibs/interceptors/auth.go:269.
2. Skip lists + service-to-service: internal/golibs/interceptors/internal_api.go:10,32-57, cmd/server/<svc>/auth.go, .claude/rules/security.md.