DEV Community

ZebedeeHolloway9023
ZebedeeHolloway9023

Posted on

How to Protect API Requests with JWKS and Session Verification During Phone OTP Recovery

Short answer: use JWKS verification for stateless API access tokens, session verification for browser recovery state, and require an explicit transition between them during phone OTP login. A valid signature proves who issued a token; it does not prove that a recovery attempt is still authorized.

That distinction matters in a B2B SaaS product where a user can lose a phone, request a one-time code, and still have an already-issued API token in a background job. The least complex design is to keep the two checks separate, record the decision, and make recovery revoke or narrow credentials deliberately.

What does each verification boundary actually prove?

JWKS verification answers a cryptographic question: does this JWT have a trusted issuer, an allowed algorithm, a known key identifier, a valid signature, and acceptable iss, aud, and time claims? The verifier retrieves public keys from the issuer's JSON Web Key Set and caches them with an expiry policy. It should reject an unknown kid rather than quietly accepting a different key.

Session verification answers a state question: is this browser session, device binding, and recovery transaction still active in the application's store? A session can be revoked immediately, marked as requiring step-up authentication, or constrained to the account-recovery endpoint. Those controls are difficult to express with a self-contained access token.

The checks therefore compose, but they are not substitutes. A signed access token can remain valid after a user starts phone recovery. Conversely, a live session cookie is not evidence that an arbitrary bearer token was issued by the right authority. I keep an audit record containing the request ID, subject, session ID, OTP transaction ID, and the reason for any denial; reconciliation is impossible when those identifiers are missing. That record also needs a clear clock source and an actor classification, because a support-assisted recovery, a normal browser action, and a service-to-service call have different review expectations. In a regulated payment-adjacent workflow, the audit entry is part of the control evidence: it should let an investigator reconstruct which key set, issuer configuration, session state, and recovery policy were evaluated without retaining the secret code or a full bearer token. The extra fields make the event larger, yet they prevent a later argument over whether a token was revoked before or after the phone number changed.

Boundary first.

How should API requests cross JWKS and session boundaries during phone OTP recovery?

Treat recovery as a short state machine. The user begins with a normal session, requests an OTP, verifies the code, and then receives a narrowly scoped recovery result. At each transition, persist a server-side record with a single-use nonce, expiry, attempt count, and the account's recovery policy. Do not let a client promote an otp_verified=true field into authority.

For ordinary API calls, validate the bearer token with JWKS and enforce its scopes. For recovery calls, require the active session plus the recovery transaction. After successful OTP verification, rotate the session identifier, invalidate competing recovery transactions, and revoke or reissue access tokens according to the application's risk policy. The exact retention period is a policy decision; OWASP's authentication guidance supports short-lived, single-use recovery material and careful rate limiting, but it does not prescribe one universal number.

Here is a deliberately small Go shape for the decision point. The cryptographic verifier and session store are interfaces so the policy remains testable and independent of a vendor SDK.

package main

import (
    "context"
    "errors"
)

type Claims struct {
    Subject string
    Scopes  map[string]bool
    Valid   bool
}

type TokenVerifier interface {
    VerifyJWKS(ctx context.Context, raw string) (Claims, error)
}

type SessionStore interface {
    Active(ctx context.Context, sessionID, subject string) bool
    RecoveryOpen(ctx context.Context, sessionID, transactionID string) bool
}

func authorize(ctx context.Context, rawToken, sessionID, transactionID string, tv TokenVerifier, ss SessionStore) error {
    claims, err := tv.VerifyJWKS(ctx, rawToken)
    if err != nil || !claims.Valid || !claims.Scopes["api:read"] {
        return errors.New("token rejected")
    }
    if !ss.Active(ctx, sessionID, claims.Subject) {
        return errors.New("session rejected")
    }
    if transactionID != "" && !ss.RecoveryOpen(ctx, sessionID, transactionID) {
        return errors.New("recovery transaction rejected")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The ordering is intentional: authenticate the token, bind its subject to the session, then evaluate recovery state. In a payment or ledger service, I would also attach an authorization decision to the audit event before the handler mutates anything. Exactly-once behavior is an aspiration, not a property granted by HTTP retries, so the OTP transaction and session rotation need idempotency keys.

Which failures should make you choose a different boundary?

The common failure is treating a successful OTP as a replacement for every other control. That creates a recovery token with broad API scope, often copied into logs or a mobile clipboard. Another failure is checking only the session: a stolen bearer token can then be replayed from a different device. A third is caching JWKS forever; key rotation turns that into a trust decision based on stale material.

Use a server-side session boundary when you need immediate revocation, device-level review, or a recovery workflow with several steps. Use JWKS at service-to-service edges where a shared issuer, audience, and scope contract is more useful than browser state. When both appear in one request, define which one can narrow access. Neither should silently broaden it.

Small rule, large consequence.

The trade-off is operational. Session state adds a lookup, expiry cleanup, and cross-region consistency work. JWKS adds key-cache refresh behavior, clock-skew handling, and issuer configuration. A small team may keep recovery on one regional session store and use short-lived access tokens elsewhere. A globally distributed product may accept a brief revocation window, provided sensitive operations demand a fresh session check.

How do you test and operate the trust decision?

Build a matrix before shipping: valid token and active session; valid token and revoked session; rotated signing key; wrong audience; expired OTP transaction; replayed OTP nonce; and a session belonging to a different subject. Each case should produce a stable denial code and an audit event without recording the OTP itself. Metrics should distinguish signature failures, session revocations, and recovery policy denials, because their responders are different.

I once assumed that a 200 from the OTP endpoint meant the account was ready for every API call. It only meant one transition had succeeded. The next review found a background worker still using an older token, which was correct behavior under its original lifetime but wrong for the recovery policy. We changed the policy to revoke that token family and made the worker re-authenticate. The fix was architectural, not a retry loop.

Keep retention bounded: store the audit facts needed for incident reconstruction and compliance review, then apply the deletion schedule required by your jurisdiction and contract. Your mileage may vary because data residency and recovery obligations differ, but the trust boundary should remain explicit in every region.

For this B2B SaaS scenario, the decision rule is concise: JWKS proves token provenance, session verification proves current application state, and phone OTP proves possession only for the narrowly defined recovery transition. If a proposed design cannot state which event revokes which credential, it is not ready for production.

References

Top comments (0)