Stage 7 · Master
Phase 4 — Authentication Service
Authentication Middleware
Build the shared middleware every downstream handler in every service relies on to know who is calling — and design its failure modes so a missing or invalid token never falls through as anonymous access.
One Middleware, Reused by Every Service
auth-service issues tokens, but it is not the only service that must verify them. user-service's membership endpoints, organization-service, and eventually API Gateway all need the same answer to 'is this token valid, and who does it belong to.' The shared middleware depends on a tiny Verifier interface rather than importing auth-service/internal/token. That preserves the internal-package boundary enforced by the repository lint rules while letting auth-service inject the concrete Ed25519 verifier at startup.
Because access tokens are self-verifying JWTs (jwt lesson), this middleware only needs a copy of auth-service's current public keys — it never calls auth-service over the network per request. Key distribution is covered in security-best-practices; this lesson assumes a KeySet is already available.
Extracting, Verifying, and Injecting Into Context
package httpmw
import (
"context"
"net/http"
"strings"
"time"
)
type ctxKey string
const claimsKey ctxKey = "hoa-claims"
type Claims struct {
Subject string
OrganizationID string
Role string
TokenID string
IssuedAt time.Time
IsPlatformAdmin bool
}
// Verifier is implemented by auth-service's token verifier adapter.
// The shared package owns policy; auth-service retains key and JWT details.
type Verifier interface {
Verify(raw string) (Claims, error)
}
// RequireAuth extracts a bearer token, verifies its signature and
// expiration, and injects the resulting claims into the request context.
// A missing, malformed, or invalid token always produces 401 — there is no
// code path that treats an unverifiable token as an anonymous request.
func RequireAuth(verifier Verifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
raw, ok := strings.CutPrefix(header, "Bearer ")
if !ok || raw == "" {
unauthorized(w, "missing bearer token")
return
}
claims, err := verifier.Verify(raw)
if err != nil {
unauthorized(w, "invalid or expired token")
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func unauthorized(w http.ResponseWriter, reason string) {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized","message":"` + reason + `"}`))
}
// ClaimsFromContext retrieves the verified claims injected by RequireAuth.
// Called by a handler that never ran behind RequireAuth, it returns false —
// handlers must check ok, never assume claims are always present.
func ClaimsFromContext(ctx context.Context) (Claims, bool) {
claims, ok := ctx.Value(claimsKey).(Claims)
return claims, ok
}The interface is also the test seam: rejection-path tests inject a deterministic fake verifier, while the composition root adapts internal/token.Verify and its KeySet. No downstream service can import auth-service's internal implementation.
Bridging net/http Middleware Into Gin, Plus Two Gin-Native Additions
RequireAuth above is deliberately plain net/http — func(http.Handler) http.Handler — so no service is forced to depend on this platform's router choice just to reuse it. Every service in this course runs Gin, though, so pkg/httpmw also owns the small bridge that lets Gin's engine mount that net/http middleware directly, plus two genuinely Gin-native pieces every service mounts first: a per-process request id, and the structured access log that reads it.
package httpmw
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog"
)
// Adapt lets this package's net/http-shaped middleware run inside a Gin
// engine. Gin still resolves the route; Adapt only bridges the two handler
// signatures, threading the same *http.Request — and any context values a
// wrapped middleware attaches to it, like verified claims — back into c so
// every Gin handler downstream sees them.
func Adapt(mw func(http.Handler) http.Handler) gin.HandlerFunc {
return func(c *gin.Context) {
reached := false
mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
c.Request = r
c.Next()
})).ServeHTTP(c.Writer, c.Request)
if !reached {
// The wrapped middleware already wrote its own rejection body
// (a 401 or 403) and never called next — stop the Gin chain here
// too, instead of falling through to a handler that expects one.
c.Abort()
}
}
}
const requestIDKey = "request_id"
// RequestID assigns an identifier scoped to this one process's handling of
// a single request. It is never sent to another service — the API Gateway
// module's correlation-id lesson introduces the header that is.
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
if id, err := uuid.NewV7(); err == nil {
c.Set(requestIDKey, id.String())
c.Writer.Header().Set("X-Request-Id", id.String())
}
c.Next()
}
}
// AccessLog is the baseline structured request log every backend service
// mounts right after RequestID. gateway-service's own richer
// internal/httpmw.AccessLog (API Gateway module) extends this same idea
// with upstream-routing and correlation-id fields a single backend has no
// need for.
func AccessLog(logger zerolog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
logger.Info().
Str("method", c.Request.Method).
Str("path", c.Request.URL.Path).
Int("status", c.Writer.Status()).
Int64("duration_ms", time.Since(start).Milliseconds()).
Str("request_id", c.GetString(requestIDKey)).
Msg("request")
}
}c.Writer.Status() is Gin's own tracked response status — no manual http.ResponseWriter wrapper is needed here the way one would be in plain net/http, since Gin's ResponseWriter already records it for every handler that runs.
package jwtverify
import (
"crypto/ed25519"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hoa-platform/backend/pkg/httpmw"
)
type Claims struct {
jwt.RegisteredClaims
OrgID string `json:"org_id"`
Role string `json:"role"`
IsPlatformAdmin bool `json:"is_platform_admin,omitempty"`
}
type Verifier struct {
publicKeys map[string]ed25519.PublicKey
}
func New(publicKeys map[string]ed25519.PublicKey) Verifier {
return Verifier{publicKeys: publicKeys}
}
func (v Verifier) Verify(raw string) (httpmw.Claims, error) {
claims := &Claims{}
parsed, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) {
kid, _ := token.Header["kid"].(string)
key, ok := v.publicKeys[kid]
if !ok {
return nil, fmt.Errorf("unknown signing key %q", kid)
}
return key, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}))
if err != nil || !parsed.Valid || claims.IssuedAt == nil {
return httpmw.Claims{}, fmt.Errorf("invalid access token")
}
return httpmw.Claims{
Subject: claims.Subject, OrganizationID: claims.OrgID, Role: claims.Role,
TokenID: claims.ID, IssuedAt: claims.IssuedAt.Time,
IsPlatformAdmin: claims.IsPlatformAdmin,
}, nil
}
var _ httpmw.Verifier = Verifier{}This concrete verifier is constructible by every service from auth-service's published Ed25519 public-key set. It cannot issue tokens because it never receives a private key.
Mounting RequireAuth Ahead of Protected Routes
The middleware order established when user-service was bootstrapped — Recoverer, then RequestID, then AccessLog — gets one new entry here, and its position is not arbitrary: RequireAuth must run after AccessLog (so even rejected requests are logged with a request ID) but before any router group containing protected handlers.
tokenVerifier := jwtverify.New(authPublicKeys)
engine := gin.New()
engine.Use(gin.Recovery())
engine.Use(httpmw.RequestID())
engine.Use(httpmw.AccessLog(logger))
protected := engine.Group("/api/v1")
protected.Use(httpmw.Adapt(httpmw.RequireAuth(tokenVerifier)))
membershipRoutes(protected.Group("/memberships"))
meRoutes(protected.Group("/users/me"))
// Deliberately outside the authenticated group: health checks must remain
// reachable by a load balancer or kubelet that never holds a bearer token.
engine.GET("/healthz", healthHandler)Defining an allowlist of 'public' routes that skip auth is one missed entry away from an accidental data leak. Scoping RequireAuth to an explicit authenticated r.Group, with public routes declared outside it, means a new protected route is insecure by omission only if someone forgets to add it to the group at all — a much more visible mistake during code review than a forgotten allowlist entry.
Testing Rejection Paths Without a Real Token Service
Applied exercise
Add a clock-skew tolerance without weakening expiry enforcement
A field report shows requests being rejected as 'expired' when a client's device clock is a few seconds fast relative to auth-service's issuing clock, even though the token has not meaningfully expired.
- Decide where a leeway window belongs: in token.Verify itself, or as a parameter passed in by RequireAuth.
- State a leeway duration you'd choose and justify it against the access token's 15-minute TTL.
- Explain why leeway must never be applied to token issuance time in a way that could extend an already-expired token's effective lifetime beyond the leeway window itself.
Deliverable
A short written decision plus the reasoning for the chosen leeway value.
Completion checks
- Leeway is bounded (seconds, not minutes) relative to the 15-minute TTL.
- The answer explains why leeway is a verification-time tolerance, not an issuance-time change.
Why does RequireAuth set a WWW-Authenticate header on every 401 response?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.