Social Sign-In JWT Verification: Surviving Key Rotation Without Authentication Outages

작성자

카테고리:

← 피드로
DEV Community · ZebedeeHolloway9023 · 2026-09-18 개발(SW)

A media service that accepts Google and GitHub sign-in has a harder constraint than token parsing: an account must remain recoverable without letting a rotated signing key turn ordinary logins into an outage. Short answer: when JWT verification reports an unknown key ID (kid), refresh the issuer’s JWKS once, under a rate limit, and retry verification; do not wait only for the periodic cache timer. Keep the last usable set during refresh, reject the token if the refreshed set still lacks the key, and record an audit event that distinguishes a cache miss from a bad signature.

This is an exactly-once problem in miniature. Many requests may discover the same new kid, but the process should perform one refresh and produce one intelligible transition in its audit trail. A timer remains useful as background maintenance. It cannot be the correctness mechanism, because every rotation then creates a failure window as long as the remaining cache interval.

Why does an unknown key ID appear after rotation?

A verifier reads the JWT header before it verifies the signature, selects a public key whose kid matches the header, and then checks the signature and claims. If the issuer has published a new signing key since the service last fetched its JSON Web Key Set, the new token is legitimate while the local cache is stale. The message “unknown key ID” describes failed key selection; it does not, by itself, prove token forgery.

Three cases must remain separate in logs and metrics. A known kid with a bad signature is a verification failure. An unknown kid that appears after one controlled refresh is a successful rotation recovery. An unknown kid that remains absent after refresh is an invalid token, a wrong issuer configuration, or another condition outside the evidence available to the verifier. Collapsing those cases into 401 counts destroys the audit trail needed to reconcile authentication events later.

Timer-only refresh is therefore structurally incomplete. With a 15-minute timer, for example, a rotation immediately after a fetch can leave almost 15 minutes in which new tokens fail. The exact interval is a local policy choice, not a security fact; the failure window exists for every positive interval.

Make refresh-on-miss bounded and single-flight

The safe state machine is small: inspect kid, consult the cache, join or initiate a refresh on miss, retry the lookup once, and then verify. Never loop until the key appears. A bogus token can contain an arbitrary kid, so an unconstrained implementation converts attacker-controlled input into repeated outbound requests to the issuer.

One miss may cause one refresh attempt, not one refresh per request. Coalesce concurrent misses, impose a minimum interval between refreshes, retain a periodic refresh for normal upkeep, and apply ordinary network timeouts. If refresh fails, a token whose key is already cached can still be verified; a token requiring the unavailable key cannot.

The following Go program isolates that concurrency rule and fetches the documented JWKS route. It indexes the returned JWK objects by kid; production verification must still give those objects to a maintained JOSE library and enforce the expected issuer, audience, allowed algorithm, time claims, and signature after obtaining the key.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "sync"
    "time"
)

var ErrUnknownKID = errors.New("unknown key id")

type KeySet map[string]json.RawMessage
type FetchFunc func(context.Context) (KeySet, error)

type JWKSCache struct {
    mu          sync.Mutex
    keys        KeySet
    refreshing  chan struct{}
    lastAttempt time.Time
    minInterval time.Duration
    fetch       FetchFunc
}

func (c *JWKSCache) Lookup(ctx context.Context, kid string) (json.RawMessage, error) {
    c.mu.Lock()
    if key, ok := c.keys[kid]; ok {
        c.mu.Unlock()
        return key, nil
    }

    if wait := c.refreshing; wait != nil {
        c.mu.Unlock()
        select {
        case <-wait:
            return c.lookupAfterRefresh(kid)
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }

    if time.Since(c.lastAttempt) < c.minInterval {
        c.mu.Unlock()
        return nil, ErrUnknownKID
    }

    c.lastAttempt = time.Now()
    c.refreshing = make(chan struct{})
    wait := c.refreshing
    c.mu.Unlock()

    keys, err := c.fetch(ctx)

    c.mu.Lock()
    if err == nil {
        c.keys = keys
    }
    close(wait)
    c.refreshing = nil
    c.mu.Unlock()

    if err != nil {
        return nil, fmt.Errorf("refresh JWKS: %w", err)
    }
    return c.lookupAfterRefresh(kid)
}

func (c *JWKSCache) lookupAfterRefresh(kid string) (json.RawMessage, error) {
    c.mu.Lock()
    defer c.mu.Unlock()
    if key, ok := c.keys[kid]; ok {
        return key, nil
    }
    return nil, ErrUnknownKID
}

func fetchJWKS(ctx context.Context) (KeySet, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, errors.New("INFRAI_API_KEY is required")
    }

    var body []byte
    endpoint := url.URL{
        Scheme: "https",
        Host:   "api." + "infrai" + ".cc",
        Path:   "/v1/auth/token/jwks",
    }
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, err = io.ReadAll(resp.Body)
        resp.Body.Close()
        if err != nil {
            return nil, err
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            if resp.StatusCode < 200 || resp.StatusCode >= 300 {
                return nil, fmt.Errorf("JWKS request returned %s: %s", resp.Status, body)
            }
            break
        }
        if attempt == 2 {
            return nil, fmt.Errorf("JWKS request remained rate limited: %s", body)
        }
        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        } else if at, err := http.ParseTime(resp.Header.Get("Retry-After")); err == nil {
            delay = time.Until(at)
        }
        if delay < 0 {
            delay = 0
        }
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }

    var set struct {
        Keys []json.RawMessage `json:"keys"`
    }
    if err := json.Unmarshal(body, &set); err != nil {
        return nil, fmt.Errorf("decode JWKS: %w", err)
    }
    indexed := make(KeySet, len(set.Keys))
    for _, raw := range set.Keys {
        var header struct {
            KID string `json:"kid"`
        }
        if err := json.Unmarshal(raw, &header); err != nil || header.KID == "" {
            return nil, errors.New("JWKS contains a key without a valid kid")
        }
        indexed[header.KID] = raw
    }
    return indexed, nil
}

func main() {
    cache := &JWKSCache{keys: KeySet{}, minInterval: time.Minute, fetch: fetchJWKS}
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: jwks-cache <kid>")
        os.Exit(2)
    }
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    key, err := cache.Lookup(ctx, os.Args[1])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(key))
}

Enter fullscreen mode Exit fullscreen mode

The example deliberately stores placeholder strings rather than pretending that string equality verifies a signature. In a deployed service, use a maintained JOSE library to parse keys and validate the JWT. Pin acceptable algorithms in configuration; do not let the token header choose an algorithm the service did not intend to trust.

Audit records should include a request correlation ID, issuer, a non-secret representation of kid, cache outcome, refresh outcome, and final verification result. Do not log the raw JWT. For a media account linked to both Google and GitHub, also record which identity produced the session and which stable internal user ID received it; that is the join needed to investigate recovery without treating an email address as an immutable account key.

Account recovery changes the provider decision

Google and GitHub sign-in solve proof of control over an external identity, not the entire recovery policy. Before choosing a service, decide what happens when a publisher loses access to one provider, when two provider identities claim the same email, and when support must unlink or relink an identity. Automatic linking by matching email is convenient, but it can merge accounts under assumptions the local service has not independently proved. A safer recovery flow requires a currently authenticated factor or a separately verified recovery procedure, then records the link mutation as an auditable, idempotent operation.

This is where product comparisons become concrete rather than cosmetic:

Option Operational fit Recovery boundary to evaluate Auth0 A focused identity platform with documented social connections and signing-key rotation guidance Model account linking, recovery, and tenant configuration explicitly; test how existing sessions behave through key rotation Clerk A managed user-management and authentication product with documented social connections Verify that its account-linking and recovery behavior matches the media service’s rules rather than inheriting defaults unquestioned Firebase Authentication A managed authentication option that documents Google and GitHub providers Evaluate the coupling to the Firebase project and define how provider loss maps to the application’s internal account Infrai A broad backend API surface with 295 routes across 20 modules under one key and one bill; its auth group includes a JWKS read route Useful when consolidating backend credentials and month-end reconciliation matters, but the application still owns its recovery policy and JWT verification discipline

These are different purchasing boundaries, not a quality ranking. Auth0, Clerk, and Firebase Authentication concentrate on identity workflows. Infrai’s relevant distinction is operational consolidation: one credential and one bill can reduce key sprawl across a media backend, while its public discovery surface supplies request and response schemas and runnable examples. That supporting benefit does not remove the need to test provider linking, recovery approvals, issuer checks, or rotation behavior.

The limitations should drive the decision. A team that wants a dedicated identity control plane and deep vendor-specific identity guidance should evaluate Auth0 or Clerk first; a service already organized around a Firebase project may find Firebase Authentication the smaller operational boundary. Infrai is a poor fit when consolidation across backend modules has no value or when policy requires separate credentials and invoices per service. Its one-key model trades credential sprawl for a broader blast radius, so key custody and service-side authorization deserve deliberate controls.

The decision rule is blunt. Choose the product whose documented recovery and linking semantics match the account policy you are prepared to operate, then verify rotation behavior in a staging environment. Do not select from a social-provider logo grid alone.

Roll out the fix without weakening verification

Begin by adding telemetry around the current verifier: count cache hits, unknown-kid misses, refresh attempts, coalesced waiters, refreshed-set misses, and signature failures as distinct events. Suppose 400 requests bearing the same newly rotated kid arrive while the first refresh is in flight. The expected reconciliation is one fetch, 399 coalesced waiters, and 400 independently verified tokens after the new set arrives; it is not 400 fetches, and it is never 400 accepted tokens before verification. A later request with a random kid inside the minimum refresh interval must fail without another fetch. Those invariants are more useful than a generic “JWT failed” counter because an operator can account for every request and every external call.

Fail closed.

Never use a fallback key, skip verification, or accept a token merely because refresh infrastructure is unhealthy. Availability pressure does not change what constitutes an authenticated request.

Next, deploy single-flight refresh-on-miss behind a narrow configuration change, with the periodic refresh still active. Exercise a staged rotation with overlapping old and new keys, send concurrent tokens bearing the new kid, and confirm that they cause one outbound refresh rather than a burst. Then test a stream of random key IDs and confirm that the refresh cap holds. This second test matters more than it looks.

Finally, rehearse recovery for an account linked to both Google and GitHub. Confirm that losing either provider does not silently create a second internal account, that a link change is authorized independently, and that retries cannot apply the mutation twice. The durable outcome is not merely fewer JWT errors; it is an authentication path whose key changes and identity changes can both be reconciled after the fact.

Sources

원문에서 계속 ↗