Backend Systems · Session and State
Signed Cookie Tokens and the Limits of Statelessness
How a signed token encodes session facts, what the signature actually guarantees, and which operations stop being possible once the server keeps no record.
The alternative to a pointer is a container. Instead of storing the session facts behind an opaque key, the server serializes them, signs the serialization, and hands the whole thing to the client. Every subsequent request carries the facts back, and validation is arithmetic rather than a network round trip. The appeal is obvious: the store disappears from the request path, so there is no lookup latency, no shared cache to operate, and no single place where all sessions live.
What the signature buys is narrower than it first appears. It guarantees that the bytes were produced by a holder of the signing key and have not changed since. It says nothing about freshness beyond whatever timestamp the payload carries, nothing about whether the subject is still permitted to act, and nothing about confidentiality.
The serialization, byte by byte
A signed token in compact form is three base64url segments separated by dots. The first is a JSON header naming the algorithm and, usually, a key identifier. The second is the payload — claims, in JWT terminology. The third is the signature over the ASCII bytes header.payload, that is, over the encoded segments including the dot, not over the decoded JSON.
Signing the encoded form rather than the decoded form removes an entire class of ambiguity. JSON permits whitespace variation, duplicate keys, and differing escape sequences, so two byte strings with identical meaning can decode to the same object. Signing the encoded representation means the verifier never has to reconstruct a canonical JSON form, and an attacker cannot reorder keys or reformat numbers to change the payload while preserving the signature.
The payload is base64url, which is an encoding, not a cipher. Anyone holding the token can decode it. A token carrying a user identifier, a role list, and an email address discloses all three to the client, to any log that records the header, and to anything that inspects traffic. RFC 7516 defines the encrypted counterpart, JWE, for claims that must not be readable; using JWS and calling the result confidential is a common and quiet mistake.
Sizes stay modest. An HMAC-SHA-256 tag is 32 bytes, or 43 base64url characters. A header with an algorithm and a key identifier runs about 40 to 60 characters encoded, and a payload with a subject, an issuer, an audience, an expiry, and one role might encode to a few hundred. The total comfortably fits the 4096-byte per-cookie limit RFC 6265 requires, until claims start accumulating group memberships or permission lists, at which point the cookie is dropped rather than truncated and the session simply vanishes for users with large entitlements.
Symmetric tags and the key distribution problem
HMAC over the encoded segments produces a tag that any party able to verify can also produce. That is fine when one service issues and verifies its own cookies. It becomes a problem the moment a second service needs to accept the same token: sharing the HMAC key with five services means any of the five can mint a token for any user, so the blast radius of a compromise is the whole fleet rather than one component.
Asymmetric signatures change that shape. With an RSA or ECDSA key pair, the issuer holds the private key and everyone else holds the public key, so verification is widely distributable and forgery requires the private key alone. The cost is verification time — verifying an ECDSA signature costs far more CPU than checking an HMAC, though both are small next to a network round trip — and a larger signature: an Ed25519 signature is 64 bytes, an ECDSA P-256 signature about 64 to 72 depending on encoding, against 32 for HMAC-SHA-256.
Verification order and the algorithm confusion family
Signature verification must come before claims are trusted, and the algorithm must come from configuration rather than from the token. The header is attacker-controlled input. If the verifier reads alg and dispatches on it, two failures follow immediately. A token declaring none asks the verifier to skip the signature check entirely, a vulnerability class that JWT libraries have had to guard against. A token declaring HS256 against a verifier that holds an RSA public key asks the verifier to compute an HMAC using bytes it publishes, which the attacker can also compute.
The fix is structural: the verification function takes the expected algorithm as a parameter, rejects a mismatch, and rejects any key material whose type does not match the algorithm family. RFC 8725 collects this and a set of related rules.
// Verify expects exactly one algorithm and one key; the token cannot choose either.
func verify(token string, key []byte) ([]byte, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, errors.New("malformed compact token")
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(parts[0] + "." + parts[1]))
want := mac.Sum(nil)
got, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(got) != len(want) {
return nil, errors.New("bad signature encoding")
}
// Constant-time compare; early exit leaks how many bytes matched.
if !hmac.Equal(got, want) {
return nil, errors.New("signature mismatch")
}
return base64.RawURLEncoding.DecodeString(parts[1])
}
The comparison is constant-time for a reason: a byte-by-byte comparison that returns on the first difference lets an attacker recover a valid tag one byte at a time, 256 guesses per position at worst. hmac.Equal and equivalents exist in every standard library for this purpose.
Time is the only revocation clock
Claims that bound validity are exp, nbf, and iat, all integer seconds since the Unix epoch. Verification compares them against the verifier’s clock, and since clocks drift, some leeway is standard — a minute or two, not an hour. Leeway is a security parameter: it is the window in which an expired token from a host with a fast clock still passes.
This is the structural limitation of the design. With no server-side record, the only way to invalidate outstanding tokens is to wait for exp, or to rotate the signing key, which invalidates every token at once. Logging one user out, disabling one account, or revoking one compromised device is not expressible in the token itself. The expiry window becomes the maximum staleness of every authorization decision the token encodes.
Three compromises exist, and each reintroduces exactly the state the design was meant to avoid.
A short exp with a refresh mechanism bounds staleness to minutes but requires a refresh endpoint and, usually, a stored refresh token — a record again, just a less frequently consulted one. Refresh Token Rotation and Replay Detection covers that half of the system.
A denylist keyed by the token’s jti claim restores per-token revocation at the cost of one lookup per request, which is most of what a session store was doing. The list only needs to hold entries until their tokens would have expired anyway, so it stays small in proportion to the revocation rate times the validity window.
A per-user token version, stored in a row that is cached aggressively, gives “log out everywhere” and account disablement without per-token bookkeeping. The cost is that the row must be read, or a cached copy must be trusted for some interval, which again is a staleness budget. Session Fixation, Rotation, and Revocation works through these options in the context of rotation events.
Key rotation without a flag day
Rotation is the one operation the design handles cleanly, provided the format anticipated it. Each token header carries a key identifier in the kid parameter. The verifier keeps a map from identifier to key, containing the current signing key and any previous key still inside its validity window. Issuance uses the newest key; verification accepts any key in the map. Rotation then proceeds by adding a key, switching issuance to it, and retiring the old entry once the longest possible token lifetime has elapsed.
Two details break naive implementations. The identifier must be treated as a lookup key rather than as a claim about the key’s properties, and unknown identifiers must fail closed rather than fall back to a default key. And retirement must be driven by the maximum exp that could have been issued under the old key, not by a fixed interval that happens to be convenient; a token issued one second before the switch is still valid for its full lifetime.
Rotating on a schedule also bounds the damage from an undetected key compromise, since the attacker’s tokens stop verifying at the next retirement. That is a different guarantee from revocation and should not be confused with it.
Where the boundary sits
The decision between pointer and container is not binary, and most systems land in the middle. A signed token can carry identity and coarse scope while the authoritative permission set is fetched and cached separately. The useful question is not whether a design is stateless but which decisions are made from data the server cannot currently observe, and for how long. Every claim in a signed token is a fact with a timestamp, and the acceptable age of that timestamp is the real design parameter — the same parameter that appears as an idle timeout in a store-backed session, viewed from the other side.
Coupling the two is common: a store-backed identifier in a cookie whose payload is a short signed envelope holding the store key, so the server gets cheap routing and a place to look when it needs authority. The failure modes of the pure forms, and the reason affinity and replication matter, are treated in Load Balancer Session Affinity and Its Failure Modes and Read-Your-Writes Across Replicated Session Stores.