Search by

solophp / tokens

Vitaliy Olos

Tokens for reset links, invitations and sessions: random secrets stored only as hashes, in Redis or in memory.

Package info

github.com/SoloPHP/Tokens

pkg:composer/solophp/tokens

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-11-04 09:00 UTC

This package is auto-updated.

Last update: 2026-11-04 09:09:43 UTC


README

Tokens for reset links, invitations and sessions: random secrets stored only as hashes, in Redis or in memory.

Latest Version on Packagist PHP Version License

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 — InMemoryTokenStore passes 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, Secret makes 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

Full 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

License

MIT. See LICENSE and the changelog.