Skip to content

Behavior in detail ​

This page explains how the stores keep their promises and where their limits are.

Design decisions ​

  • Only the hash is stored. A secret is at least 256 random bits, so its SHA-256 is all a store needs to find it again, and reading the store gives nobody a usable token. Unlike a password, a secret cannot be guessed, so it needs neither a salt nor a slow hash.
  • One store per kind of token. A store has no notion of purpose. Separate stores keep a reset link from opening a session.
  • The subject is yours. A store never interprets it: an email address, a user id or anything else that tokens are revoked by.
  • No database store. Tables differ from one application to the next. An application that keeps tokens in its own rows uses Secret and checks expiry and single use in its own transaction.

Storage on Redis ​

RedisTokenStore uses two kinds of keys under its prefix:

KeyTypeHoldsExpires
{prefix}token:{sha256 of the secret}hashsubject, data (JSON), expires (Unix milliseconds)with the token
{prefix}subject:{sha1 of the subject}sorted sethashes of the subject's tokens, scored by their endwith the subject's longest token

When the client has OPT_PREFIX set, it comes before {prefix}. The secret itself appears in no key and no value.

Every issue() removes the subject's ended tokens from its sorted set, so the set of an active subject stays as large as its live tokens. consume() and revoke() remove the token from the set as well.

The Lua scripts build these keys themselves: consume() learns the subject's set only from the token. That is why the store needs a single Redis server, with or without replicas, and not Redis Cluster.

Atomicity ​

Each operation is one Lua script, and Redis runs a script without interleaving other commands. consume() reads the token and deletes it in that one script, so of concurrent callers exactly one gets it; the test suite starts 20 processes on one token and counts one success. issue() writes the token and its place in the subject's set in one script too.

revokeAll() followed by issue() is two scripts. Two overlapping pairs for the same subject can leave both new tokens valid.

Expiry and time ​

On Redis, issue() reads the Redis clock, stores the end in the token and sets the key to expire at that same moment. find() and consume() return that stored end, so expiresAt is the moment Redis removes the token, however long the reply takes to arrive. A token whose end has passed is treated as gone, even in the millisecond before Redis deletes its key.

In memory, the store's PSR-20 clock decides. A token ends when the clock reaches expiresAt. Lifetimes are elapsed time: one that crosses a daylight-saving change neither gains nor loses an hour.

Data ​

Data is encoded with JSON_PRESERVE_ZERO_FRACTION, so 1.0 comes back as a float, and decoded into arrays, so objects come back as arrays. Values nested more than 512 levels deep are refused by issue(), because they could not be read back. Both stores go through the same JSON, so a test on the in-memory store sees what production sees.

Damaged records ​

A record is damaged when its key holds another Redis type, a field is missing, the end is not a number, the key never expires or the data is not JSON. Such a record can only come from outside the store, and each operation answers it in one way:

OperationDamaged record
find()throws TokenStoreException, the record stays
consume()removes the record, then throws TokenStoreException
revoke()removes the record, no exception

Failures ​

A Redis command failure, a refused connection mode or an unexpected reply raises TokenStoreException, with phpredis's RedisException as its previous exception when there was one. The caller decides what an outage means: a session check might treat it as signed out, a reset form as "try again later".

Testing ​

The package runs one contract test suite against both stores. The Redis suite also covers stored hashes, key expiry against the Redis clock, the subject index dropping ended tokens, every kind of damaged record, the client key prefix and 20 processes consuming one token at once. CI runs it on PHP 8.3, 8.4 and 8.5 with Redis 7.4.

MIT licensed.