Session 03 Β· πŸ›‚ Middleware

Issuing & Validating JWTs

Sign a token at login; check it once, in one middleware, for every protected route.

βœ“
3aissue an access token
βœ“
3bauth middleware
βœ“
3cclaims in context

3a

A typed claims struct, signed at login

Embed jwt.RegisteredClaims for the standard fields and add only what you need.

internal/auth/token.go
type Claims struct {
    Role string `json:"role"`
    jwt.RegisteredClaims  // sub, iat, exp, jti…
}

func IssueAccess(userID, role string,
    ttl time.Duration) (string, error) {
    now := time.Now()
    c := Claims{
        Role: role,
        RegisteredClaims: jwt.RegisteredClaims{
            Subject:   userID,
            IssuedAt:  jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
        },
    }
    t := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
    return t.SignedString(secret)
}
β†’Typed struct over MapClaims β€” no claims["sub"].(string) assertions later.
β†’Called once, right after CheckPassword succeeds β€” typically with 15*time.Minute.
β†’secret is loaded from the environment at startup (session 10).

3b

One middleware guards every route

Five real requests hit GET /me. Watch which check stops each one.

internal/auth/middleware.go
func RequireAuth(next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
        h := r.Header.Get("Authorization")
        raw, ok := strings.CutPrefix(h, "Bearer ")
        if !ok {
            http.Error(w, "missing token", 401)
            return
        }
        c := &Claims{}
        _, err := jwt.ParseWithClaims(raw, c, keyFn,
            jwt.WithValidMethods([]string{"HS256"}))
        if err != nil {
            http.Error(w, "invalid token", 401)
            return
        }
        ctx := context.WithValue(r.Context(), ctxKey{}, c)
        next.ServeHTTP(w, r.WithContext(ctx))
    }
    return http.HandlerFunc(fn)
}

// wiring
mux.Handle("GET /me", RequireAuth(http.HandlerFunc(me)))
go run ./demos/03 Β· httptest requests
no header     β†’ 401 missing token
garbage       β†’ 401 invalid token
expired       β†’ 401 invalid token
alg:none      β†’ 401 invalid token
valid         β†’ 200 hello user 42 (user)
  reason: token has invalid claims: token is expired
  reason: token signature is invalid: signing method none is invalid
β†’WithValidMethods pins the algorithm β€” a token claiming alg: none is refused.
β†’Clients get a generic invalid token; log the real reason server-side.
β†’Wrap only protected routes β€” /login and /register stay public.

3c

Handlers read claims, never tokens

The middleware verified once; handlers just ask the context who is calling.

internal/auth/context.go
type ctxKey struct{} // unexported: no collisions

func ClaimsFrom(ctx context.Context) *Claims {
    c, _ := ctx.Value(ctxKey{}).(*Claims)
    return c
}

func me(w http.ResponseWriter, r *http.Request) {
    c := ClaimsFrom(r.Context())
    fmt.Fprintf(w, "hello user %s (%s)", c.Subject, c.Role)
}
β†’Unexported key type β€” no other package can overwrite or read your value by accident.
β†’One helper, ClaimsFrom, is the only way in β€” easy to fake in tests.
βœ“Sessions 05–06 read c.Role and c.Subject from here to authorize.
Mistake

Trusting a user id from the request body or query string. The only trustworthy identity is c.Subject from a verified token.

TASK

Protect GET /me

  • POST /login returns {"access_token": "…"} on success.
  • Wrap GET /me in RequireAuth; return the user from c.Subject.
  • Write httptest cases for all five requests above.

Recap

Session 03 in four lines

1Embed jwt.RegisteredClaims in a typed struct; sign with HS256 at login.
2One middleware: read Bearer header β†’ parse & verify β†’ attach claims.
3Pin the algorithm with WithValidMethods; answer every failure with 401.
4Handlers call ClaimsFrom(ctx) β€” identity never comes from user input.