Security Engineering · Authentication
Opaque Session Identifiers Versus Signed Tokens
How server-side session lookup and self-contained signed tokens differ in revocation, verification cost, and failure behaviour when a key or a datastore goes away.
Two designs dominate how a server remembers that a request is authenticated. One stores a record and hands the client a random pointer to it. The other packs the claims into a value the client carries, signs it, and trusts the signature on the next request. The difference is not stylistic. It changes what revocation costs, what verification costs, what happens when a component fails, and which attacks become possible.
The pointer model
An opaque identifier is a random string with no structure. The server stores a record keyed by a hash of that string, holding the identity, the issue time, the expiry, and whatever context the application needs. On each request the server hashes the presented value, looks it up, and checks the record.
The properties follow from the lookup. Revocation is a delete: the record goes away and the identifier stops working on the next request. Changing what a session is allowed to do is an update to the record. Listing a user’s active sessions is a scan over an index. The identifier itself carries no information, so there is nothing in it to tamper with, and the only attack surface is the entropy of the value and the security of the store.
The cost is a datastore round trip on every authenticated request. That round trip is not necessarily expensive, since a local in-memory lookup is a hash probe and a remote one is a network hop, but it is on the critical path of every request, and it makes the datastore a hard dependency for serving authenticated traffic. A cache in front of the store reduces the cost and reintroduces a staleness window: a revocation takes effect when the cache entry expires, not when the record is deleted.
The store also has to survive. If the session store is lost, every user is logged out. For a single database that is an availability event; for a store that is not replicated, it is a data loss event.
The self-contained model
A signed token carries its claims in the value. The server verifies the signature and reads the claims directly. In the JWT form defined by RFC 7519, the payload is a JSON object with registered claims such as sub, iat, exp, aud, and iss, and the signature is computed over the base64url-encoded header and payload per RFC 7515.
Verification is a signature check plus a set of claim validations. The check is cheap in CPU terms: HMAC-SHA-256 over a few hundred bytes is a handful of microseconds, and an RSA or ECDSA verification is tens to hundreds of microseconds depending on the curve and key size. Neither involves a network round trip, which is the entire point: any server holding the verification key can validate the token without consulting anything.
That property is what makes the model attractive for a fleet of services. Each service verifies independently, none needs a shared session store, and the token can be handed to a service that has never seen the user before. The cost moves to the key: every verifier needs the public key (or the shared secret, for HMAC), and the key’s distribution becomes the availability dependency instead of the datastore.
The signature covers integrity, not confidentiality. A signed token is readable by anyone holding it; the payload is base64url, not encryption. A token that carries a role or a tenant identifier is therefore a token whose contents the client can read, which is fine, and whose contents the client must not be able to change, which is what the signature enforces. Encrypting the payload is possible and is a separate construction with its own key management.
Revocation, and why it is the decisive difference
The self-contained model has no record to delete. A token that has been issued is valid until it expires, wherever it is presented, because validity is a property of the signature and the claims rather than of any server state. Everything about revocation follows from that.
The remedies are all partial. Short expiry limits the window: a token with a fifteen-minute lifetime is unusable fifteen minutes after issuance, and a refresh token with a longer lifetime and a server-side record carries the revocation. That hybrid is what OAuth 2.0 deployments converge on, and RFC 6749’s separation of access tokens from refresh tokens exists for exactly this reason. The access token is short-lived and self-contained; the refresh token is long-lived and revocable, and it is the thing that is actually stored.
A revocation list is the other remedy: a set of identifiers that have been revoked before their expiry, checked on each request. That reintroduces the per-request lookup the stateless model was meant to avoid, though the lookup is against a small set rather than a full session store. In practice a revocation list is a cache with a TTL, so a revoked token remains usable for the propagation delay.
There is a third remedy that is worse than it looks: a token version or epoch counter stored per user and embedded in the token. Verifying it requires reading the current counter, which is a per-user lookup. It is cheaper than a full session lookup and it buys the ability to invalidate all of a user’s tokens at once, which is what a password change or a compromise response needs. The lookup is small and cacheable, and the cost is that the counter becomes a piece of state that must be consistent across the fleet.
The practical conclusion is that the stateless property is real only for tokens that are allowed to remain valid. Any system that needs immediate revocation, and most consumer authentication systems do, ends up with a stateful component. The question is where the state lives and how large the staleness window is, not whether state exists.
Claims that must be validated, not just read
Verifying the signature is necessary and not sufficient. A token whose signature is valid can still be presented in the wrong context, and the standard failure modes are all claim-validation failures.
The alg header field is attacker-controlled in the token itself, and the classic defect is a verifier that reads the field and dispatches to the named algorithm without constraining the set. A token with alg: none and an empty signature is the degenerate case; a token signed with HMAC using the public key as the secret is the subtler one, because a verifier that accepts both RSA and HMAC and uses one key for both will verify an HMAC token forged with a key it published. The fix is to fix the algorithm at the verifier rather than to read it from the token, and to use distinct keys for distinct algorithms.
The aud claim must match the service that is verifying. A token issued for one audience and accepted by another is a confused deputy, and it is the mechanism by which a token stolen from a less-protected service grants access to a more-protected one.
The iss claim must match the expected issuer, and the issuer’s key must be selected from that issuer’s key set rather than from any key that happens to verify. exp must be checked against the verifier’s clock with a small tolerance, and nbf if present. Clock skew across a fleet is real, and a tolerance of a minute or two is common; the tolerance is also a window in which an expired token still works, which is the trade.
The sub claim identifies the subject, and a token that identifies a user is a bearer credential for that user. Nothing about the signature prevents replay; a stolen token works until it expires. Sender-constrained tokens, where the token is bound to a key the client holds, are the mitigation, and they are what the DPoP and mTLS-bound variants of OAuth 2.0 add.
Verification cost, and what it does to a hot path
The performance argument for signed tokens is usually stated as “no database lookup”. That is accurate and incomplete. The comparison is between a datastore round trip and a signature verification, and the numbers depend on which signature.
An HMAC verification is on the order of a microsecond, comparable to or faster than a local cache lookup and far faster than a network round trip. An RSA-2048 verification is on the order of a hundred microseconds on a general-purpose core, which is more expensive than a local lookup but still cheaper than a remote one. An ECDSA verification over P-256 is in the same range as RSA at comparable security. A token verification path that does an asymmetric check per request and then a remote introspection call has the cost of both.
The costs that get forgotten are key distribution and rotation. A fleet that verifies with a shared HMAC secret has a secret replicated to every verifier, which widens the blast radius of a single compromised service. A fleet that verifies with a public key has a key distribution problem: the key set must be fetched, cached, and refreshed, and the refresh must handle the case where a key is rotated while tokens signed with the old key are still valid. The conventional answer is a key identifier in the token header and a published key set with overlapping validity, which is workable and is genuinely more moving parts than a session table.
Key rotation deserves the emphasis because it is where the model most often fails in practice. Rotating a signing key invalidates every token signed with the old key unless verifiers keep the old public key for at least the token lifetime. A rotation that is done in a hurry, with the old key removed immediately, logs out every user with an active token. The overlap window must be at least the maximum token lifetime plus the clock-skew tolerance, and it must be a documented property of the rotation procedure rather than an operational habit.
Failure behaviour
The two models fail differently, and the difference matters for the design of the rest of the system.
When the session store is unavailable, the pointer model cannot authenticate anyone. Requests either fail closed, which is an outage, or fail open, which is worse. There is no useful degraded mode.
When the verification key is unavailable, the token model fails the same way, but the key is small, static, and cacheable, so the window is usually shorter. The failure that is specific to the token model is a key that is wrong rather than absent: a verifier configured with the wrong key rejects valid tokens, or accepts tokens signed by an attacker who obtained the key. The first is an outage, the second is a compromise, and the two are hard to distinguish from the verifier’s side.
The other asymmetry is blast radius. A compromised session store lets an attacker mint sessions for any user. A compromised signing key lets an attacker mint tokens for any user, for any audience the key is trusted by, and the tokens are valid at every service that holds the corresponding verification key. The key is a more concentrated asset than the store, and it should be held accordingly, in a component whose access is restricted and whose use is audited.
The choice between the two is not a matter of principle. A single application with a database it already depends on gains little from self-contained tokens and loses immediate revocation. A fleet of services with independent deployment needs a way to authenticate a request without a shared session store, and short-lived signed tokens with a revocable refresh path are the standard answer. The hybrid is not a compromise between the two models; it is the design that the constraints actually select.
Both models depend on the same underlying property: the credential is a bearer value, and whoever holds it is the user. Bounding how long that is true, and how widely it can travel, is the subject of session identifier rotation and of cookie scope. The token format decides where the state lives; it does not change what the state is protecting.