Getting started
Solo Tokens hands out random secrets for reset links, invitations and sessions, and keeps only their hashes. A TokenStore holds one kind of token: it issues a secret for a subject, finds or consumes the token behind a secret, and revokes one token or all tokens of a subject. Secret makes and hashes secrets on its own, for tokens kept in a row of your own.
Install
PHP 8.3+ is required. Composer installs psr/clock ^1.0 with the package.
composer require solophp/tokensRedisTokenStore needs the redis PHP extension and Redis 5.0 or later. InMemoryTokenStore needs nothing else.
Create a store
use Solo\Tokens\RedisTokenStore;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$resets = new RedisTokenStore($redis, 'app:password-reset:');
$sessions = new RedisTokenStore($redis, 'app:session:');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.
Pass a connection in its normal mode: not inside MULTI or a pipeline, and without OPT_SERIALIZER or OPT_COMPRESSION. The store refuses any other with TokenStoreException. A client OPT_PREFIX is honored and comes before the store's own prefix.
Send a password-reset link
// 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}");issue() returns a secret of 64 hex characters and stores only its hash, with the subject and the lifetime. The subject is whatever the token belongs to: an email address, a user id, a cart.
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.
Spend it once
$token = $resets->consume($submittedToken) ?? throw new InvalidResetLink();
$users->setPassword($token->subject, $newPassword);consume() returns the token and removes it in the same step. Of two requests that arrive with the same link at the same moment, one gets the token and the other gets null. If setting the password fails after that, the link is spent and the person asks for a new one.
Anything that cannot be a secret, such as an empty string, a short value or upper-case hex, returns null without a call to Redis.
Keep a 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;find() returns the live token and leaves it usable. A token ends when its lifetime runs out, when it is consumed or when it is revoked.
$sessions->revoke($_COOKIE['session']); // sign out here
$sessions->revokeAll((string) $user->id); // sign out everywhereCarry data with a token
$secret = $invitations->issue($email, ttlSeconds: 72 * 3600, data: [
'role' => 'manager',
'invitedBy' => $admin->id,
]);
$token = $invitations->consume($submitted);
$role = $token?->data['role'];Data is any array that encodes as JSON, up to 512 levels deep. It comes back from JSON: floats keep their zero fraction, and objects come back as arrays. expiresAt tells when the token ends.
Keep a token in your own table
When a token belongs in a row you already have, such as an invitation on an account, use Secret directly:
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.
Test with the in-memory store
use Solo\Tokens\InMemoryTokenStore;
$store = new InMemoryTokenStore($clock); // a PSR-20 clock the test can move
$secret = $store->issue('user-1', ttlSeconds: 60);
$clock->advance(60);
assert($store->find($secret) === null);InMemoryTokenStore keeps the same contract as RedisTokenStore, and data goes through the same JSON. Omit the clock to use system time.
Further reading
- API reference: every type and method.
- Behavior in detail: storage, atomicity, expiry and failures.