solophp / tokens
Tokens for reset links, invitations and sessions: random secrets stored only as hashes, in Redis or in memory.
Requires
- php: >=8.3
- psr/clock: ^1.0
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^12.0
- squizlabs/php_codesniffer: ^3.13
Suggests
- ext-redis: For RedisTokenStore, the store every process shares
Provides
None
Conflicts
None
Replaces
None
README
Tokens for reset links, invitations and sessions: random secrets stored only as hashes, in Redis or in memory.
Features
- Only the hash is stored — the secret goes into the link or the cookie, the store keeps its SHA-256. Whoever reads the store, a replica or a backup gets nothing they can sign in with.
- Single use that holds under load —
consume()finds and removes a token in one step, so two clicks on one reset link at the same moment cannot both pass. On Redis that step is one Lua script. - Revoke one or all — end one token, or every token issued for a subject, such as all sessions after a password change.
- Data travels with the token — a small JSON-encodable array, such as an email address or a return URL, is stored with the token and comes back as it was issued.
- Garbage costs nothing — a value
Secret::generate()could not have made is refused before the store is asked. - Secrets stay out of traces — every parameter that carries a secret is marked
#[\SensitiveParameter]. - Failures are loud — Redis errors, damaged records and unexpected replies raise
TokenStoreException. - In-memory twin —
InMemoryTokenStorepasses the same contract tests as the Redis store and takes a PSR-20 clock, so tests can move time. - The secret on its own — when a token belongs in a row of your own, such as an invitation on an account,
Secretmakes and hashes it the same way.
Installation
composer require solophp/tokens
Requires PHP 8.3+. For Redis, enable the redis PHP extension and pass a connected Redis client in its normal, immediate execution mode. The scripts read the Redis clock before writing, which needs Redis 5.0 or later; the integration suite uses Redis 7.4.
Documentation
Send a password-reset link
use Solo\Tokens\RedisTokenStore; $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $resets = new RedisTokenStore($redis, 'app:password-reset:'); // The form asks for a link. Links asked for earlier stop working. $resets->revokeAll($email); $secret = $resets->issue($email, ttlSeconds: 3600); $mailer->send($email, "https://example.com/reset?token={$secret}"); // The link comes back with a new password. $token = $resets->consume($submittedToken) ?? throw new InvalidResetLink(); $users->setPassword($token->subject, $newPassword);
issue() returns the secret and stores only its hash. consume() returns the token and removes it in the same step, so it works once. If setting the password fails after that, the link is spent and the person asks for a new one.
revokeAll() and issue() are two steps, not one. When two requests for the same address overlap, both new links can stay valid. Each still works once, and both went to the same inbox.
Keep a session
$sessions = new RedisTokenStore($redis, 'app:session:'); $secret = $sessions->issue((string) $user->id, ttlSeconds: 30 * 86400); setcookie('session', $secret, ['httponly' => true, 'secure' => true, 'samesite' => 'Lax']); $userId = $sessions->find($_COOKIE['session'] ?? '')?->subject; $sessions->revoke($_COOKIE['session']); // sign out here $sessions->revokeAll((string) $user->id); // sign out everywhere
find() leaves the token usable. A token ends when its lifetime runs out, when it is consumed or when it is revoked.
Give each kind of token its own store: a prefix per kind on Redis, an instance per kind in memory. A store does not know what a token is for, so a reset link kept in the session store would open a session.
Keep a token in your own table
use Solo\Tokens\Secret; $secret = Secret::generate(); $accounts->update($accountId, [ 'invitation_hash' => Secret::hash($secret), 'invitation_expires_at' => new DateTimeImmutable('+72 hours'), ]); // Later, when the invitation link comes back: $hash = Secret::hash($submitted); $account = $hash === null ? null : $accounts->findOneBy(['invitation_hash' => $hash]);
Secret::hash() returns null for anything generate() could not have made, so garbage never reaches the query. A plain SHA-256 is enough because a secret is at least 256 random bits: unlike a password, there is nothing to guess, so it needs neither a salt nor a slow hash. Expiry and single use are then yours to check, in the same transaction as the work they guard.
API
| Method | Behavior |
|---|---|
Secret::generate(int $bytes = 32): string |
Random bytes in lowercase hex, 32 to 64 bytes. |
Secret::hash(string $secret): ?string |
SHA-256 in hex, or null when the value is not a secret generate() could have made. |
issue(string $subject, int $ttlSeconds, array $data = []): string |
Stores a token for the subject and returns its secret. |
find(string $secret): ?Token |
The live token, which stays usable, or null. |
consume(string $secret): ?Token |
The live token, removed in the same step, or null. Of concurrent calls, one gets it. |
revoke(string $secret): void |
Ends the token. A missing token is ignored. |
revokeAll(string $subject): void |
Ends every token issued for the subject. |
A Token carries subject, data and expiresAt. The subject must not be empty, the lifetime runs from 1 to 2_147_483_647 seconds and the data must encode as JSON; otherwise issue() throws InvalidArgumentException before storing anything. Data comes back from JSON: floats keep their zero fraction, and objects come back as arrays.
Storage and failures
RedisTokenStore keeps a token at $prefix . 'token:' . $hash, a hash of its subject, data and end that expires with the token. $prefix . 'subject:' . sha1($subject) is a sorted set of the subject's tokens by their end, for revokeAll(): every issue() drops the ended ones, and the set expires with its longest token. Ends are taken from the Redis clock, so expiresAt is the moment Redis removes the token, whatever the delay before the reply arrives. When the client has Redis::OPT_PREFIX set, it comes before $prefix. Each operation is one Lua script that builds these keys itself, so the store needs a single Redis server, with or without replicas, not Redis Cluster. Use a connection outside MULTI and pipelines, without OPT_SERIALIZER or OPT_COMPRESSION: phpredis would apply them to the script arguments, so the store refuses such a connection.
A Redis command failure, a damaged record or an unexpected reply raises Solo\Tokens\Exception\TokenStoreException. find() reports a damaged token and leaves it, consume() removes it and then reports it, and revoke() removes it without an error. If phpredis throws RedisException, it is available via getPrevious().
InMemoryTokenStore keeps tokens per instance and periodically drops expired ones. A long-running process can call purgeExpired() to drop them immediately and get the number removed. Inject a PSR-20 clock to control expiry in tests.
use Solo\Tokens\InMemoryTokenStore; $store = new InMemoryTokenStore($clock); // Omit the clock to use system time.
Development
Run the full suite, including Redis and the concurrent-process test, with Docker Compose.
docker compose -f compose.test.yaml --profile test run --build --rm tests docker compose -f compose.test.yaml --profile test down --volumes
With a local PHP installation, composer test validates the package, checks PSR-12, runs PHPStan at maximum level and runs the unit suite. Redis store unit tests require the redis extension. To run the integration suite, point it at a disposable Redis server.
composer install
composer test
REDIS_TEST_HOST=127.0.0.1 REDIS_TEST_PORT=6379 composer test-integration