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:

internal/usermgmt/pkg/interceptors/auth.go:79 (condensed)
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):

internal/golibs/interceptors/auth.go:269 (TokenVerifier.Verify, condensed)
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.

The two-package footgun (again) This live interceptor is in 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.

Why the difference matters 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
Read this next

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?

The interceptor puts UserID, UserGroup, roles, and full claims on ctx; everything downstream reads …FromContext(ctx).

Q2. Which four checks make up JWT verification here?

Verify the RS256 signature against the JWKS key, then check issuer, audience, and expiry. Multiple issuers; first success wins.

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.
Recall: what the auth interceptor does + the two skip lists.
the flow + both lists, then reveal
On every RPC (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).
🎯 Interview one-liner "Where is authentication enforced?" → "A gRPC interceptor on every RPC. It pulls the token from metadata, verifies the RS256 signature against the cached JWKS plus issuer/audience/expiry, and stamps the verified claims onto the request context — so identity flows to every layer, including the DB's tenant variable. Truly-public methods are allow-listed; internal service-to-service calls get a synthesized, org-scoped identity."
🏁 Part 1 complete — authentication You can now explain the whole "who are you?" story: the JWT and its 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.
Part 2 opens authorization — the role gate (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.