JWT vs opaque API tokens: sessions, revocation, and scalability trade-offs
Compare signed JWTs with opaque server-side tokens for APIs: verification cost, revocation semantics, storage, and patterns that hold up under mobile, B2B, and high-QPS backends.
You ship an authentication service that mints a JSON Web Token (JWT) on login. For months everything is fine—stateless verification at the edge, no database round-trip on each request. Then a customer demands instant logout after a password reset, your security team asks for per-token revocation on suspected compromise, and product wants session lists in the account UI. Suddenly “stateless” feels like a constraint you bolted on too early. The alternative—opaque bearer tokens resolved against a store—looks simpler for revocation but introduces hot keys, latency, and consistency questions at scale.
This article compares the two approaches on dimensions that matter in production: who verifies, how revocation works, what you store, and how the choice composes with gateways, microservices, and third-party integrations. The goal is not to crown a winner but to give a decision framework so you pick the shape that matches your threat model and operational budget.
What each token type actually is
JWT as a bearer credential
A JWT (typically JWS in APIs) is a compact, signed string with three logical parts: header, payload, claims. The verifier checks the signature using a shared secret (HS256) or a public key (RS256, ES256) and optionally validates standard claims such as exp, nbf, aud, and iss.
The important property: all information needed for verification travels with the request (plus keys already held by the verifier). No call to the issuer is strictly required on the read path—hence “stateless” verification at API gateways and services.
Opaque token as an identifier
An opaque token is an uninterpretable string (random bytes, ULID, prefixed identifier). It has no intrinsic meaning until the resource server asks an introspection endpoint, a session store, or a token cache whether the token is valid, to whom it was issued, and what scopes or roles apply.
The important property: validity is externalized. The token is a primary key into server-side state you control.
Verification cost and the edge of “stateless”
JWT verification is CPU-bound cryptography—cheap per request at moderate scale, but it multiplies across every service that validates independently and across every worker in a horizontally scaled tier. At very high QPS, verification and JSON parsing become measurable; teams sometimes push validation to an API gateway and forward internal headers, which trades cryptographic cost for trust in the edge and careful header hygiene.
Opaque resolution is I/O-bound: a Redis GET, a regional DynamoDB read, or a call to a central introspection service. Latency is predictable in the sense of “one network hop,” but that hop becomes a dependency with its own SLO. Caching introspection results reduces load but reintroduces staleness: a revoked token may remain “valid” in edge caches until TTL expires.
Why this matters: “stateless JWT” is not free—it shifts work from a database to every verifier’s CPU and to key distribution. Opaque tokens shift work to a central lookup with clearer revocation semantics but a scalability choke point unless you shard and cache aggressively.
Revocation, logout, and incident response
JWTs are not revocable by default. Until exp, any party holding the signing material’s public half (for asymmetric algorithms) will accept a well-formed token. Common mitigations include:
- Short lifetimes (minutes) plus refresh tokens handled server-side (often opaque and revocable).
- Denylist (blocklist) of
jticlaim values checked on each request—reintroducing a store and partially negating statelessness. - Key rotation with overlapping
kidvalues—useful for compromise of signing keys, not for revoking a single user session.
Opaque tokens inherit immediate invalidation: delete the row or flip a revoked_at column and the next lookup fails. That property is why session tables and OAuth access tokens stored in SQL remain popular for products that sell enterprise session policies (idle timeout, concurrent session limits, admin-driven logout).
Trade-off: JWT advocates often pair access JWTs with opaque refresh tokens—a hybrid that keeps hot paths fast while preserving revocable long-lived credentials. The mistake is treating the access JWT as a long-lived session without a denylist when the product contract requires instant global logout.
What goes in the payload: identity vs authorization
JWTs tempt teams to embed permissions, tenant IDs, and feature flags in claims to avoid database reads in every handler. That works until:
- Roles change mid-session and the JWT lags until refresh.
- Token size grows enough to hurt HTTP/2 header compression or mobile uplinks.
- You leak internal identifiers to clients that log URLs or crash reports.
A disciplined pattern is: JWT carries stable subject (sub), audience, issuer, expiration, and perhaps tenant—minimal claims needed for routing and coarse authorization. Fine-grained authorization still hits the policy store or attribute service when correctness requires fresh data.
Opaque tokens often store only a session id; authorization data lives entirely server-side, which simplifies “what can this token do?” updates but concentrates read load on the session layer.
Practical architecture: hybrid access pattern
The following sketch shows a common production compromise: short-lived JWT access tokens minted after validating an opaque refresh token (and optional rotating refresh family). It is illustrative—real systems add binding to client id, DPoP or mTLS, rate limits, and structured audit logs.
import { randomBytes, createHash } from "node:crypto";
type RefreshRecord = {
userId: string;
familyId: string;
revoked: boolean;
expiresAt: number;
};
/** In-memory stand-in for Redis / SQL session store */
const refreshStore = new Map<string, RefreshRecord>();
function hashToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
function issueOpaqueRefreshToken(): string {
return randomBytes(32).toString("base64url");
}
/** Pseudocode: mint a short-lived JWT using your library of choice (e.g. jose). */
function mintAccessJwt(input: { userId: string; expiresInSec: number }): string {
void input;
return "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...";
}
export async function rotateRefreshSession(rawRefresh: string): Promise<{ accessJwt: string; newRefresh: string } | null> {
const key = hashToken(rawRefresh);
const row = refreshStore.get(key);
if (!row || row.revoked || row.expiresAt < Date.now()) return null;
// Detect refresh token reuse: same token presented twice → revoke family
row.revoked = true;
const newRaw = issueOpaqueRefreshToken();
refreshStore.set(hashToken(newRaw), {
userId: row.userId,
familyId: row.familyId,
revoked: false,
expiresAt: Date.now() + 14 * 24 * 60 * 60 * 1000,
});
return {
accessJwt: mintAccessJwt({ userId: row.userId, expiresInSec: 600 }),
newRefresh: newRaw,
};
}
The why behind rotating opaque refresh tokens with a family id is theft detection: if an attacker replays a stolen refresh token after the legitimate user has rotated, both sides reveal compromise and you can revoke the whole family—patterns aligned with modern OAuth practice (see the separate article on authorization code with PKCE and refresh rotation for the full OAuth-shaped version).
When to prefer which
Lean toward JWT access tokens when:
- Many internal services must authenticate requests and you want decentralized verification with public keys.
- Traffic is extremely high and a central session lookup per request is hard to budget.
- Session staleness of a few minutes is acceptable for authorization, or you keep JWT claims minimal and still call policy services where needed.
Lean toward opaque access tokens when:
- Strong revocation and session inventory are first-class product requirements.
- You already operate a low-latency session cluster you trust (Redis, managed cache) with clear TTL and eviction policy.
- Tokens are only consumed by your first-party APIs, not third parties that cannot reach your introspection endpoint.
Hybrid (short JWT + opaque refresh) is the default shape for many consumer and B2B SaaS APIs because it balances edge verification with recoverable long-lived credentials.
Common mistakes and pitfalls
-
Long-lived JWTs as the only credential. Convenience becomes a liability when HR offboards someone or a device is lost—without a denylist or very short TTL, access continues until expiration.
-
Oversized JWTs in headers. Browsers, proxies, and gateways enforce header limits; stuffing claims “to avoid DB reads” eventually breaks edge cases and leaks data to intermediaries.
-
Introspection without caching boundaries. Uncached introspection on every microservice hop adds N times latency; over-cached introspection delays revocation visibility.
-
Treating “signed” as “encrypted.” JWS protects integrity, not confidentiality. Sensitive claims belong in server-side session records or encrypted JWE with explicit key management—not in a payload clients can base64-decode.
-
Ignoring clock skew and
nbf. Distributed verification fails mysteriously when VMs drift; include skew tolerance and monitoriat/nbfrejections.
Conclusion
JWTs and opaque tokens solve different slices of the session problem. JWTs optimize for verification without a synchronous issuer dependency; opaque tokens optimize for mutability and centralized control. Most mature APIs end up combining them: JWTs or signed macaroons for narrow, time-bounded access where replay cost is bounded, and opaque identifiers where humans expect the system to forget them immediately after logout or policy change.
In freelance and consulting work on APIs and identity boundaries, the recurring lesson is to write down the revocation story before choosing formats—operations and security reviews always ask that question first. For background on engineering focus and collaboration on scalable systems, see About; for architecture reviews or implementation support, Contact is the right place to start.
订阅邮件通讯
新文章发布时收到邮件。无垃圾信息 — 仅本博客的新文章通知。
由 Resend 发送,可在邮件中退订。