idct / single-use-token-manager
Single-use token manager built on the PSR-16 simple cache contract, with optional tag based invalidation.
Package info
github.com/ideaconnect/single-use-token-manager
pkg:composer/idct/single-use-token-manager
Requires
- php: ^8.2
- psr/simple-cache: ^3.0
- symfony/serializer: ^6.4 || ^7.0
- symfony/uid: ^6.4 || ^7.0
- symfony/validator: ^6.4 || ^7.0
Requires (Dev)
- behat/behat: ^3.31
- friendsofphp/php-cs-fixer: ^3.95
- idct/php-rapid-cache-client: ^1.1.1
- infection/infection: ^0.32
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.5
- symfony/cache: ^6.4 || ^7.0
- symfony/property-access: ^6.4 || ^7.0
- zircote/swagger-php: ^4.1 || ^5.0 || ^6.0
Suggests
- idct/php-rapid-cache-client: PSR-16 Redis/Valkey cache with tag support and an atomic take(), so clearAllTokens() drops only tokens and a token cannot be redeemed twice by simultaneous callers
- symfony/cache: Wrap any PSR-6 adapter as PSR-16 with Symfony\Component\Cache\Psr16Cache
- zircote/swagger-php: Read the OpenAPI attribute on TokenIdentifier when generating an API schema
README
Hand a user something they can redeem exactly once: a password reset link, an email confirmation, a one-off download, a payment callback that must not run twice. The library issues a token into a cache and takes it back out again, and the cache handles expiry, so there is no sweeping job to write and nothing to migrate.
Storage is any PSR-16 cache. When the cache also supports tagging, clearing tokens reaches the tokens and nothing else.
Installation
composer require idct/single-use-token-manager
You also need a PSR-16 cache. Two that work well:
# Redis or Valkey. Gives you tag-scoped clearing and single use that holds # under concurrency, both picked up automatically. composer require idct/php-rapid-cache-client # Or wrap any Symfony PSR-6 adapter as PSR-16 composer require symfony/cache
Getting started
use IDCT\SingleUseTokenManager\TokenService; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; $service = new TokenService(new Psr16Cache(new ArrayAdapter())); // Issue a token that lives for an hour. $token = $service->createToken('reset', ['user_id' => 123], 3600); $token->getUid(); // '7c6b0c0c-d9b0-45af-a9c6-79ab7dd5c35d' $token->getType(); // 'reset' $token->getPayload(); // ['user_id' => 123] // Redeem it. The token is removed from the cache as it is returned. $redeemed = $service->consumeToken($token->getUid()); // A second attempt finds nothing, which is what makes it single use. $service->consumeToken($token->getUid()); // null
example.php in the repository walks through the same ground in more detail and
runs as it is:
composer install php example.php
How it works
A token is stored as one cache entry under the key TKN_ plus its identifier.
The identifier is a UUID v4, built from random_bytes(). That matters more than
it looks: a token is a bearer capability, so whoever holds the identifier can
perform the action, and only a cryptographically secure source makes it
unguessable. Time ordered versions are deliberately avoided, for reasons set out
in Why the identifier is random. Expiry is the cache's job: a
lifetime handed to createToken() goes straight through to the PSR-16 write, so
an expired token simply is not there any more.
consumeToken() reads the entry and removes it in the same call. It returns
null for a token that was never issued, one that has expired, one already
redeemed, and one whose cache entry turned out to hold something else, so
calling code only needs the single null check. How that behaves when several
callers arrive together is covered in Concurrency.
The API
TokenService
| Method | What it does |
|---|---|
createToken(string $type, mixed $payload = null, ?int $ttl = null): TokenInterface |
Issues a token and stores it. Throws InvalidArgumentException for an unusable type and TokenStorageException when the cache refuses the write. |
consumeToken(string $uid, bool $keepToken = false): ?TokenInterface |
Redeems a token, removing it unless $keepToken says otherwise. Returns null when there is no live token under that identifier. |
clearAllTokens(): bool |
Drops every token. See Clearing tokens for what that costs on a cache without tagging. |
The token type
A type is lowercase letters and digits, one to sixteen characters. That keeps it
safe to put in a cache key or a URL without escaping. reset, verify2fa and
invite are fine; Password Reset, pass_reset and an empty string are
refused with an InvalidArgumentException before anything is written.
The payload
The payload is whatever the code redeeming the token will need. It travels through the cache, so it has to survive that cache's serialisation. Arrays and plain objects are safe; open resources and closures are not.
Checking a token without spending it
Pass keepToken: true to look a token up without redeeming it. This is for
read-only checks, such as rendering a page that says the link is still valid:
// Rendering a "your reset link is valid" page. Nothing is spent. $token = $service->consumeToken($uid, keepToken: true);
Do not use it as a gate in front of a second, redeeming call:
// Wrong. Two requests can both pass the check and both do the work. if (null === $service->consumeToken($uid, keepToken: true)) { throw new NotFoundHttpException(); } doTheExpensiveThing(); $service->consumeToken($uid);
The gap between the check and the spend is exactly where a second request gets in, and the work in the middle widens it. Spend the token first, then do the work:
$token = $service->consumeToken($uid); if (null === $token) { throw new NotFoundHttpException(); } // The token is spent. Nobody else can redeem it. doTheExpensiveThing($token->getPayload());
Reporting a refused write
PSR-16 write methods report failure by returning false rather than by throwing.
A service that ignored that would hand back a token the cache never stored, and
the user would be sent something they could never redeem. createToken() turns
the false into a TokenStorageException instead.
consumeToken() treats a refused delete the same way, raising a
TokenRemovalException. A token the cache would not remove is still redeemable,
so reporting the failure is better than telling the caller it was spent. The
redeemed token is not returned in that case: losing one legitimate redemption
beats leaving a token open to replay.
Why the identifier is random
A single-use token is a bearer capability: possession of the identifier is the whole authorisation. That puts it in the same category as a session cookie, not the same category as a database primary key, and it has to be unguessable.
Time ordered UUIDs are not. A v6 identifier carries a node that Symfony computes once per process and then repeats on every token that process issues, so tokens from one php-fpm worker all share their last twelve characters:
1f18890c-1563-6ae2-a9b7-77b0849864a3
1f18890c-1563-6c9a-a438-77b0849864a3
1f18890c-1563-6ccc-988e-77b0849864a3
^^^^^^^^^^^^ identical for the life of the process
One leaked token gives that away for all the others, and the rest is mostly a clock. An attacker who can request a token for their own account learns the node and pins the time, leaving far too little to guess. A v7 identifier is no better here: Symfony seeds it once and then increments, so consecutive values sit next to each other.
RFC 9562 makes the same point in its security considerations: do not assume a UUID is hard to guess, and do not use one as a security capability unless it comes from a CSPRNG.
So the identifier is a v4, which Symfony builds from random_bytes(). The cost
is the loss of time ordering, which only ever bought cache index locality, and
that is worth very little for entries fetched by exact key that expire on their
own.
Concurrency
Whether a token really is single use depends on what the cache can do.
PSR-16 has no way to take a value. Reading is get() and removing is
delete(), two calls with a gap between them, and every caller that arrives
inside that gap reads a token that is still there. Eight processes redeeming one
token simultaneously against a plain PSR-16 cache all succeed.
When the cache can read and remove in one operation, consumeToken() uses that
instead and exactly one caller wins. Declare the capability by implementing
IDCT\SingleUseTokenManager\Contract\AtomicCacheInterface:
public function take(string $key): mixed; // return the value and remove it, atomically
The service detects the method rather than the interface, so any cache carrying
a compatible take() is used as-is. Backing it is usually one command: Redis
and Valkey have had GETDEL since 6.2, which phpredis exposes as getDel().
idct/php-rapid-cache-client ships take() from 1.1 onwards, so it gives
you both halves at once: tag-scoped clearing and single use that holds under
concurrency, with no wiring beyond constructing it.
use IDCT\Cache\RapidCacheClient; use IDCT\Cache\RedisConnectionConfig; $cache = new RapidCacheClient(new RedisConnectionConfig(host: '127.0.0.1', port: 6379)); // Tagging and atomic redemption are both picked up automatically. $service = new TokenService($cache);
tests/Concurrency/single-use-under-load.php demonstrates both halves. It lines
eight processes up on one token and asserts that an atomic cache yields exactly
one winner while a plain one does not, so neither this section nor the guarantee
can drift without the build noticing:
$ composer test:concurrency
rapid cache client: 1 of 8 redeemers won
plain PSR-16: 8 of 8 redeemers won
If more than one request can present the same token at once, and both succeeding would matter, use a cache that can take atomically.
Clearing tokens
clearAllTokens() behaves differently depending on what the cache can do.
On a plain PSR-16 cache the only bulk operation available is clear(),
which empties the entire pool. Anything else sharing that pool goes with the
tokens. Give the token service its own cache pool if that matters.
On a cache that supports tagging every token is written under the tag TKN
and clearing invalidates just that tag. Nothing else in the pool is touched.
The service works this out on its own, by checking whether the cache exposes
setTagged() and clearByTag(). No configuration and no wiring:
use IDCT\Cache\RapidCacheClient; use IDCT\Cache\RedisConnectionConfig; use IDCT\SingleUseTokenManager\TokenService; $cache = new RapidCacheClient(new RedisConnectionConfig(host: '127.0.0.1', port: 6379)); $cache->set('unrelated', 'some other value'); $service = new TokenService($cache); $service->createToken('reset', ['user_id' => 123]); $service->clearAllTokens(); $cache->get('unrelated'); // still 'some other value'
To declare the capability on a cache of your own, implement
IDCT\SingleUseTokenManager\Contract\TaggedCacheInterface. Its two methods have
the same signatures as the ones on IDCT\Cache\CacheServiceInterface, so a
cache satisfying one satisfies the other, and this package keeps
idct/php-rapid-cache-client an optional dependency rather than a required one.
Sharing a cache between services
By default every token service writes keys as TKN_<uid> and tags them TKN.
Two services sharing one pool would therefore share a key space, and clearing
one would clear the other. Pass a namespace to keep them apart:
$resets = new TokenService($cache, 'reset_'); $invites = new TokenService($cache, 'invite_'); $token = $resets->createToken('reset', ['user_id' => 123]); $invites->consumeToken($token->getUid()); // null, it is not theirs $invites->clearAllTokens(); // leaves the reset tokens alone
The namespace prefixes both the key and the tag. Leaving it out keeps the keys byte for byte as earlier versions wrote them, so this is safe to adopt on an existing deployment.
PSR-16 reserves the characters {}()/\@:, and a namespace containing any of
them is refused at construction rather than left to fail later as an unreadable
cache error.
Note that on a cache without tagging clearAllTokens() still empties the whole
pool, because clear() is all PSR-16 offers. The namespace separates the two
services' keys, not their blast radius. See Clearing tokens.
Validating an identifier from a request
TokenIdentifier is the request object an endpoint hydrates before redeeming. It
carries Symfony Validator, Serializer and OpenAPI attributes, so the identifier
is validated, named token in JSON, and documented in the API schema without
the endpoint hand rolling any of it.
use IDCT\SingleUseTokenManager\Model\TokenIdentifier; use Symfony\Component\Validator\Validation; $validator = Validation::createValidatorBuilder() ->enableAttributeMapping() ->getValidator(); $identifier = new TokenIdentifier(); $identifier->token = $request->get('token'); if (count($validator->validate($identifier)) > 0) { throw new BadRequestHttpException(); } $token = $service->consumeToken($identifier->token);
An empty identifier is rejected, and so is one made only of whitespace, which would otherwise slip through as a cache miss further down the line.
The OpenAPI attribute is inert unless you install zircote/swagger-php, which
is why this package does not require it. PHP does not load an attribute class
until something reflects over it, so the attribute costs nothing when the
generator is absent. Install it when you want the schema:
composer require --dev zircote/swagger-php
Layout
src/
├── Contract/
│ ├── TaggedCacheInterface.php PSR-16 plus setTagged() and clearByTag()
│ ├── TokenInterface.php What a token guarantees
│ └── TokenServiceInterface.php What the service guarantees
├── Exception/
│ └── TokenStorageException.php Raised when the cache refuses a write
├── Model/
│ ├── Token.php Immutable token with a random UUID v4 identifier
│ └── TokenIdentifier.php Validated request object
└── TokenService.php The service itself
Requirements
- PHP 8.2 or newer, tested on 8.2, 8.3, 8.4 and 8.5
- A PSR-16 cache
- Docker and Docker Compose, for the functional tests that need a server
Testing
composer install composer analyse # PHPStan at level max composer test:unit # PHPUnit with coverage composer test:bdd:memory # Behat, no container needed composer test:bdd # Behat against every cache, starts the containers composer test:mutation # Infection composer test # all of the above composer lint # coding standard
Every one of these runs on every CI job, across PHP 8.2 to 8.5 and against both the newest and the lowest allowed dependency resolution. Nothing is gated to a single job, so a failure that only shows up under one version or one resolution still fails the build.
The suite holds four lines and the build fails below any of them: 100% line
coverage, 100% method coverage, a mutation score index of 100%, and PHPStan at
level max over src, tests and example.php. There is no PHPStan baseline
and no ignored error, so a new finding fails the build instead of being
recorded and forgotten. Coverage is published to
Codecov from each
job, flagged by PHP version and dependency mode.
What the functional tests cover
The scenarios in features/service run unchanged against three different
caches, because the service is meant to need nothing beyond PSR-16:
| Suite | Cache | Tagging | Needs a server |
|---|---|---|---|
model |
none | not applicable | no |
array_cache |
Psr16Cache over ArrayAdapter |
no | no |
redis_no_tags |
Psr16Cache over RedisAdapter |
no | Redis on 6379 |
rapid_cache_tags |
RapidCacheClient |
yes | Valkey on 6380 |
Run one at a time with ./vendor/bin/behat --suite=rapid_cache_tags. The
containers behind the last two come from docker-compose.yml:
composer cache:start # start Redis and Valkey composer cache:stop # stop them composer cache:clean # stop them and drop the volumes
Changelog
CHANGELOG.md records what changed in each release. If you are
coming from 1.x under the old GryfOSS namespace, it also carries the upgrade
steps.
Licence
BSD 3-Clause. See LICENSE.