DEV Community

MarenCrest5138
MarenCrest5138

Posted on

Edtech Token Migration — 4 Controls for Session and Public-Key Verification

Short answer: keep an edtech developer portal's browser login as an opaque, server-side session; use short-lived, public-key-verified access tokens at API boundaries; and rotate refresh tokens as a family so one replay can revoke the stolen session without rotating a signing key.

Choice Logout and theft response Verification path Migration cost Best fit
Server-side opaque session Immediate record revocation Session-store lookup Moderate; dual-read old and new sessions Browser portal and sensitive account actions
Signed access token Bounded by token lifetime unless a denylist is added Cached public key Lower coupling for many API services Distributed API calls
Opaque access token Immediate at the authorization service Introspection or shared store More network and runtime dependency Central policy enforcement

The recommendation is the first two rows together, with a strict boundary between them. A portal cookie identifies a server-side session. An API access token carries a narrow audience and expires quickly. A rotating refresh token creates the next access token, but never enters browser JavaScript. This split adds one translation step at the portal backend, yet it gives the stolen-session response a concrete control point during migration off a managed provider.

Don't start by copying every field the old provider emitted. Start with the revocation invariant.

How should developer portal authentication combine sessions and public-key verification?

Treat authentication state as three different objects because they fail differently. The browser session answers, "May this browser use the portal now?" The access token answers, "May this caller use this API for this audience until this time?" The refresh token answers, "May this session mint another access token?" Folding all three jobs into one long-lived signed token makes the first call look fast, but logout becomes a policy wish rather than an enforceable state change.

Public-key verification belongs where independent services need to validate an access token without sharing a signing secret. The verifier must pin accepted algorithms, validate the issuer and audience, reject expired tokens, and select a trusted key by identifier. RFC 8725 explicitly warns that libraries must let callers specify supported algorithms and that a token must not choose its own verification rules. Cache the public key set, but give that cache an expiry and a controlled refresh path. Key rotation is routine signing-key hygiene; it is not the response to one stolen refresh token.

The browser boundary is less glamorous. It also matters more. Put a high-entropy opaque value in a cookie with Secure, HttpOnly, and an appropriate SameSite policy, then store only a hash of that value in the session database. OWASP recommends cookies as the session ID exchange mechanism and warns against accepting session identifiers through URLs. Regenerate the session identifier after authentication or any privilege change. A learner who becomes an instructor should not carry the pre-change session ID across that boundary.

This costs a lookup.

Measure it instead of arguing from vibes: record session-store latency separately from total request latency, split cache hits from database reads, and watch the tail during enrollment deadlines. The important DX metric is not one synthetic hello-world call. It is how many pieces of configuration a service owner must understand to verify an access token correctly: issuer, audience, accepted algorithms, key-set location, cache expiry, clock tolerance, and failure behavior. If every service invents those knobs, migration has merely moved the managed provider's complexity into repository sprawl.

Make refresh rotation a single state transition

A refresh token should be single-use. Each successful exchange invalidates the presented token and creates its successor in the same family. If an already-consumed token appears again, assume that one copy may be stolen, revoke the whole family, revoke the linked portal session, and require authentication. OAuth 2.0 Security Best Current Practice describes rotation this way: the authorization server retains the relationship between old and new refresh tokens so it can detect replay and revoke the active token.

Atomicity is the trap. Two requests can present the same refresh token within milliseconds: perhaps the browser retried after losing a response, or perhaps an attacker raced the legitimate client. A read followed by an update leaves both callers a chance to win. The storage operation has to consume the current token only when its status and generation still match, then insert the successor in the same transaction. One winner gets the next pair. The loser gets 401 with a stable machine-readable code such as refresh_reuse_detected; the server then closes the family. Harsh? Yes. It is the only deterministic answer when the server cannot tell which copy belongs to the learner.

The data model can stay small. Store hashes, not bearer credentials, and keep the interface boring enough to test without an identity SDK.

type RefreshRecord = {
  tokenHash: string;
  familyId: string;
  sessionId: string;
  generation: number;
  status: "active" | "consumed" | "revoked";
  expiresAt: Date;
};

type RotationResult =
  | { kind: "rotated"; refreshToken: string; accessToken: string }
  | { kind: "replay" }
  | { kind: "expired" };

interface RefreshStore {
  rotateAtomically(input: {
    presentedHash: string;
    expectedGeneration: number;
    next: RefreshRecord;
  }): Promise<RotationResult>;

  revokeFamily(familyId: string): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

Hash comparison, token generation, and signing sit outside this storage interface. That separation makes three tests obvious: exactly one of two concurrent rotations succeeds; replay revokes the family; and a revoked family cannot rotate even with its newest token. Add expiry-boundary tests with an injected clock. Time-dependent auth code without a controllable clock is config bloat wearing a test helper's hat.

Migrate by preserving decisions, not token shapes

Run the old provider and the new session authority in parallel for a bounded migration window. New logins create only new sessions. Existing cookies first go to the new store; on a miss, the portal validates through the old provider, creates a new local session after successful validation, and expires the old cookie. Keep the two cookie names distinct so logs can identify which path made the decision. Do not mint a local session from unverified legacy claims, and do not let fallback turn into permanent dual authority.

The dangerous shortcut is dual-write. If logout must update two systems and either update can fail, nobody can state confidently whether the learner is signed out. Prefer a single revocation owner for each session generation. During the window, the cookie namespace tells the portal which owner is authoritative. After the maximum legacy session lifetime has passed, remove the fallback and its configuration in the same release train. Less glue wins.

For an incident involving a stolen instructor session, revoke the portal session and its refresh-token family in one administrative action, then record an append-only security event with the actor, target user, session ID, family ID, reason, and timestamp. Do not log raw cookies, access tokens, or refresh tokens. Return the same generic authentication response to the client; OWASP recommends generic error messages because different responses can reveal whether an account exists or which authentication step failed. Internally, distinguish session_revoked, refresh_reuse_detected, audience_mismatch, and unknown_signing_key so an operator can see the failure mode without leaking it to the browser.

I'm not sure which signal will catch theft first in a given portal; that depends on available telemetry and the attacker. Resolve that uncertainty with observable events: rotations per family, replay detections, forced reauthentication, verification failures by reason, fallback validations, and active legacy sessions. Alert on changes from the portal's own baseline rather than inventing a universal threshold. A count of zero legacy sessions sustained beyond the old maximum lifetime is the migration exit signal.

When is the runner-up the better choice?

Use signed sessions or signed access tokens as the primary state when requests must be verified across many independently deployed services and a central lookup would violate an established availability or latency requirement. The catch is revocation. Keep lifetimes short, scope audiences narrowly, and accept that a stolen token may remain usable until expiry unless every verifier checks a denylist. A denylist restores immediate revocation, but it also restores shared state, so document that trade instead of calling the design stateless.

Choose opaque access tokens with introspection when central policy must take effect on every request and the platform already operates that dependency. Stick with the managed provider during the migration when the team cannot yet own key rotation, cookie security, atomic refresh rotation, incident tooling, and on-call response. Moving auth because the API looks easy is a bad bet. The operational contract is the product.

There is another limit: a server-side portal session is not suitable for a native or third-party client that cannot rely on the portal's cookie boundary. Those clients need a standards-based authorization flow and their own secure token-handling model. Do not stretch the browser design until it becomes a homegrown identity protocol.

No one option wins every column. For this edtech portal, immediate revocation and a clean migration owner outweigh a session-store read; public-key verification still earns its place one boundary later, where APIs need local validation without a shared secret.

References

Further reading

Top comments (0)