DEV Community

TrippDonovan5461
TrippDonovan5461

Posted on

Edtech Phone Login Gateway — Token Validation, JWKS Rollover, and Recovery Paths

Short answer: make the gateway cache verified JWKS responses, refresh on an unknown key ID, and fail closed while preserving a separate phone-account recovery path. A key rollover should be boring; a learner locked out of a class should not be.

Design choice Pick this when Trade-off
Cache-first, refresh on kid miss The identity service publishes stable JWKS and the gateway must stay fast A bad cache policy can delay a legitimate rollover
Scheduled refresh plus kid miss refresh You control issuer publishing and can budget a little background traffic More moving parts and metrics to operate
No local cache A tiny deployment has very low request volume and an exceptionally reliable issuer Login latency and issuer coupling rise quickly
Grace window for retired keys Tokens are short-lived and issuer rollover is coordinated A wider acceptance window extends exposure after revocation

For a student-facing edtech app, I would start with the middle row: a bounded cache, a single-flight refresh, and a short overlap window for old keys. Keep recovery independent. SMS delivery, backup codes, and support verification are account decisions, not reasons to accept an unverifiable token.

What should a gateway validate before a phone-code session exists?

The gateway sits between the mobile client and course services. Picture a narrow pipe: bytes enter, a signed token leaves the validation stage, and only then does a request reach enrollment or lesson APIs. The pipe needs explicit gates.

First parse the compact JWS without trusting any claim. Resolve the issuer's JWKS by a configured HTTPS URL, select the key by kid, and verify the signature with an algorithm you allow by policy. RFC 8725 calls out algorithm confusion as a design risk, so an incoming alg value is not permission to try every verifier.

Then check iss, aud, exp, and nbf with a small clock-skew allowance. Bind the subject to the account record created after the phone one-time code succeeds. A phone number alone is not a durable identity: numbers get recycled, and a recovery flow must prove control of the account through a second, documented factor.

I log a decision ID, issuer, kid, algorithm, and reason code, but never the token or phone number. A 401 means the caller must authenticate again. A 403 means the token is valid but the account lacks permission for that course. Mixing those signals makes support and alerting painful.

Three words: fail closed.

How do JWKS retrieval, cache rotation, and failure handling fit together?

Treat the key set as signed-configuration input with a lifecycle, not as a random HTTP dependency. On a cache hit, verify locally. On an unknown kid, allow one request to trigger a refresh; concurrent requests await that same promise. If the refreshed set still has no matching key, reject the token and emit a typed metric.

A timer can refresh before the normal TTL expires, but it must not stampede the issuer after a process restart. Add jitter, cap the response size, require TLS, and honor a useful HTTP cache directive when it does not violate your maximum staleness policy. Keep the previous set during an overlap period so tokens minted just before rollover remain verifiable. The overlap is a policy choice; it is not a license to accept an expired token.

Keys rotate.

Consider a concrete Tuesday morning sequence. At 09:00 the issuer publishes kid=blue alongside kid=green; at 09:05 a pod receives a token signed by green while its local cache still contains only blue. The first request starts the single refresh, the other 47 requests wait on it, and all 48 either verify with the new key set or receive the same token_kid_unknown decision. At 09:06 the issuer removes blue, but a token minted at 08:59 can still pass during the configured overlap if its exp is valid. At 09:20, after the overlap and token lifetime have elapsed, that same token is denied. This timeline gives on-call engineers something testable: cache age, refresh count, and denial reason should line up with each transition.

Here is the core shape in TypeScript. The verifier is deliberately an interface so the same policy can run with a library, a sidecar, or a self-hosted key service.

type Jwk = { kid: string; kty: string; alg?: string; use?: string };
type KeySet = { keys: Jwk[]; fetchedAt: number };

interface KeySource {
  fetch(): Promise<KeySet>;
}

interface SignatureVerifier {
  verify(token: string, key: Jwk, algorithms: readonly string[]): Promise<boolean>;
}

class KeyCache {
  private current?: KeySet;
  private previous?: KeySet;
  private refreshInFlight?: Promise<KeySet>;

  constructor(
    private readonly source: KeySource,
    private readonly ttlMs: number,
    private readonly overlapMs: number
  ) {}

  async keyFor(kid: string, now = Date.now()): Promise<Jwk | undefined> {
    const hit = this.find(kid, now);
    if (hit) return hit;

    const fresh = await this.refresh(now);
    return fresh.keys.find((key) => key.kid === kid);
  }

  private find(kid: string, now: number): Jwk | undefined {
    const sets = [this.current, this.previous];
    for (const set of sets) {
      if (!set || now - set.fetchedAt > this.ttlMs + this.overlapMs) continue;
      const match = set.keys.find((key) => key.kid === kid);
      if (match) return match;
    }
    return undefined;
  }

  private async refresh(now: number): Promise<KeySet> {
    if (!this.refreshInFlight) {
      this.refreshInFlight = this.source.fetch().then((next) => {
        if (this.current) this.previous = this.current;
        this.current = { ...next, fetchedAt: now };
        return this.current;
      }).finally(() => {
        this.refreshInFlight = undefined;
      });
    }
    return this.refreshInFlight;
  }
}

async function authenticate(
  token: string,
  cache: KeyCache,
  verifier: SignatureVerifier
): Promise<{ sub: string }> {
  const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString());
  if (typeof header.kid !== 'string') throw new Error('token_kid_missing');

  const key = await cache.keyFor(header.kid);
  if (!key) throw new Error('token_kid_unknown');

  const ok = await verifier.verify(token, key, ['RS256']);
  if (!ok) throw new Error('token_signature_invalid');

  const claims = decodeAndValidateClaims(token, {
    issuer: 'https://identity.example.edu',
    audience: 'course-api',
    clockSkewSeconds: 60
  });
  return { sub: claims.sub };
}
Enter fullscreen mode Exit fullscreen mode

The example refreshes after a kid miss, but the policy still needs a freshness check. In production I make the source reject oversized or malformed JSON, and I enforce an allowlist of issuers per tenant. Do not infer an issuer from an untrusted host header.

Pick a recovery path before you tune cache seconds

Phone-code login creates two clocks: the code's short validity and the signing key's much longer lifetime. They should fail differently. An expired code gets a new challenge. An unavailable JWKS leaves the protected request denied while the user can still reach a clearly labeled recovery screen. That screen can ask for backup codes, a previously enrolled authenticator, or a support process with audit trails.

For a primary-school account, a parent or guardian relationship may be part of recovery. For a university account, campus identity proofing may be the right authority. The gateway should carry a recovery outcome such as recovery_required; it should not mint a session merely because the phone number matches a profile.

Test the ugly sequences: old token plus retired key, new token plus cold cache, duplicate refreshes, a clock 90 seconds ahead, malformed kty, and a network timeout during refresh. Use deterministic fixtures and assert both the HTTP status and the reason metric. A dashboard that only says 401 rate cannot distinguish an attack from an issuer rollover.

My alert set is small: refresh error rate, unknown-kid count, cache age, verification latency, and recovery-required rate. I page on sustained freshness failure, not on one learner's typo.

Limits and decision notes

This pattern is unsuitable when tokens must be revoked instantly across every region; use an introspection or central authorization decision in that case, accepting its availability dependency. It is also a poor fit for highly intermittent clients that cannot reach the identity service during enrollment; design an explicit offline enrollment process instead of stretching key overlap.

Stick with a simpler cache when one issuer, one audience, and short token lifetimes are contractual. Add a key-set version signal and staged rollout when several issuers or tenants share the gateway. I'm not sure any single TTL is correct for every school calendar; measure issuer rollover practice, token lifetime, and recovery volume, then set the policy from those observations.

The useful outcome is predictable behavior: valid tokens survive an orderly rollover, invalid tokens never get a bypass, and a learner has a documented way back into the account.

References

Top comments (0)