ptondereau / biscuit-symfony-bundle
Symfony bundle for Biscuit authorization tokens
Package info
github.com/ptondereau/biscuit-sf-bundle
Type:symfony-bundle
pkg:composer/ptondereau/biscuit-symfony-bundle
Requires
- php: >=8.1
- ext-biscuit_php: >=0.5
- symfony/config: ^6.4 || ^7.0 || ^8.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/security-bundle: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^10.0 || ^11.0
- ptondereau/biscuit-php-stubs: ^0.5.1
- symfony/cache: ^6.4 || ^7.0 || ^8.0
- symfony/maker-bundle: ^1.50
- symfony/web-profiler-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
This package is auto-updated.
Last update: 2026-08-05 23:28:45 UTC
README
Symfony bundle for Biscuit authorization tokens.
About
Biscuit is a bearer token format with offline attenuation, third-party blocks, and a Datalog-based authorization language. This bundle integrates Biscuit into Symfony's Security component so you can authenticate requests carrying Biscuit tokens and enforce policies through the standard #[IsGranted] attribute.
What you get:
- Token extraction from
Authorizationheader and/or cookies - Symfony authenticator that validates the token's signature against your public key
- A
BiscuitVoterthat runs your Datalog policies against the request, fully driven by#[IsGranted] - Token attenuation through reusable block templates, with an event for audit and a console command for debugging
- Configurable token caching and revocation checking
- A web profiler panel showing the current token, its blocks, every policy decision, and every attenuation performed during the request
- Console commands to generate keys, mint tokens from templates, attenuate tokens, and inspect tokens
- A
make:biscuit-policymaker - Test helpers to mint tokens and authenticate functional tests
Read the Datalog reference at biscuitsec.org/docs/reference/datalog.
Requirements
- PHP 8.1 or higher
- Symfony 6.4, 7.4, or 8.0
- The
biscuit-phpPHP extension (version 0.5.0)
Installation
Install the PHP extension via pie:
pie install ptondereau/biscuit-php:0.5.0
Install the bundle via Composer:
composer require ptondereau/biscuit-symfony-bundle
If you are not using Symfony Flex, register the bundle manually in config/bundles.php:
return [ // ... Biscuit\BiscuitBundle\BiscuitBundle::class => ['all' => true], ];
Quick Start
Generate a key pair:
bin/console biscuit:keys:generate
Configure the bundle (config/packages/biscuit.yaml):
biscuit: keys: public_key: '%env(BISCUIT_PUBLIC_KEY)%' private_key: '%env(BISCUIT_PRIVATE_KEY)%' policies: admin_only: 'allow if role("admin")'
Wire the authenticator into your firewall (config/packages/security.yaml):
security: firewalls: api: pattern: ^/api stateless: true custom_authenticators: - biscuit.authenticator
Protect a controller with #[IsGranted]:
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; final class AdminController { #[Route('/api/admin', methods: ['GET'])] #[IsGranted('admin_only')] public function index(): Response { return new JsonResponse(['ok' => true]); } }
That's it. Requests without a valid token get 401, requests whose token does not satisfy the policy get 403.
Configuration Reference
biscuit: keys: public_key: ~ # Public key in hex private_key: ~ # Private key in hex public_key_file: ~ # Path to public key file (alternative to public_key) private_key_file: ~ # Path to private key file (alternative to private_key) algorithm: ed25519 # ed25519 or secp256r1 security: user_identifier_fact: user # Authority block fact holding the user identifier www_authenticate: true # Send an RFC 6750 challenge on failure realm: api # Realm advertised in that challenge token_extractor: header: true # Extract token from Authorization header cookie: false # Cookie name to read from, or false to disable revocation: enabled: false # Enable revocation checking on_unavailable: ~ # allow or deny; REQUIRED when enabled dispatch_check_events: on_revoke # never, on_revoke or always default_expiry: ~ # Seconds to keep an entry of unknown expiry stores: static: ids: [] # Revoked ids, or '%env(csv:BISCUIT_REVOKED_IDS)%' file: ~ # Newline-delimited or JSON list of revoked ids cache: enabled: false pool: ~ # Null creates cache.biscuit.revocation adapter: cache.app # Parent adapter for that pool key_prefix: biscuit_revoked_ in_memory: enabled: false doctrine: enabled: false connection: doctrine.dbal.default_connection table: biscuit_revoked_tokens push: enabled: false # Broadcast writes over Symfony Messenger bus: messenger.default_bus # Bus used to dispatch and to register handlers policies: # Named policies referenced by #[IsGranted] admin_only: 'allow if role("admin")' scope_read: 'allow if scope({resource}, "read")' token_templates: # Templates used by BiscuitTokenFactory and biscuit:token:create admin_token: facts: - 'user({id})' - 'role("admin")' checks: - 'check if time($t), $t < {expiry}' rules: [] block_templates: # Templates used by BiscuitBlockFactory and biscuit:token:attenuate read_only: checks: - 'check if operation("read")' expires: checks: - 'check if now($t), $t <= {exp}' authorizer_fact_templates: # Facts injected into the authorizer, keyed by #[IsGranted] policy name credit_authorized: facts: - 'operation("credit_wallet")' - 'amount({amount})'
Key Management
The KeyManager service exposes the configured key pair. Three input forms are supported:
- Inline hex via
keys.public_keyandkeys.private_key(recommended via env vars). - Files via
keys.public_key_fileandkeys.private_key_file. - A new pair generated on demand if none is configured (only useful in tests).
To generate a fresh pair from the CLI:
bin/console biscuit:keys:generate --algorithm=ed25519
The command prints the hex-encoded keys to stdout. Store them in your secrets manager and inject them via environment variables.
Token Extraction
Tokens are extracted by the ChainTokenExtractor, which delegates to one or more named extractors in order:
HeaderTokenExtractorreadsAuthorization: Bearer <token>from the request.CookieTokenExtractorreads a configurable cookie name.
Enable both:
biscuit: security: token_extractor: header: true cookie: biscuit_token
The chain stops at the first extractor that returns a non-null value. To add a custom extractor, implement Biscuit\BiscuitBundle\Token\Extractor\TokenExtractorInterface and register it as a service tagged into the chain.
Authentication
The bundle ships a single authenticator: BiscuitAuthenticator. Add it as a custom_authenticators entry on any stateless firewall that should accept Biscuit tokens.
A successful authentication produces a BiscuitUser whose getBiscuit() method returns the verified token, which is then available throughout the request.
The BiscuitBadge is attached to the security passport so downstream voters and listeners can detect Biscuit-authenticated requests.
The authenticator does not deny anonymous requests. supports() returns false when no token
is present, so a request without one never reaches the authenticator. Deny it yourself with
access_control or #[IsGranted], or the endpoint you believe is protected is public:
security: firewalls: api: pattern: ^/api stateless: true provider: your_provider custom_authenticators: - biscuit.authenticator entry_point: biscuit.authenticator access_control: - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
Setting entry_point lets the bundle answer anonymous requests too, so they get the same JSON
body and WWW-Authenticate challenge as a rejected token.
Failure Responses
Failures return 401 with an RFC 6750 body and challenge, so clients can tell "get a new token" apart from "you sent nothing":
| Cause | WWW-Authenticate |
Body error |
|---|---|---|
| No token sent | Bearer realm="api" |
invalid_request |
| Malformed, forged or revoked token | Bearer realm="api", error="invalid_token", ... |
invalid_token |
Turn the header off with biscuit.security.www_authenticate: false, and change the realm with
biscuit.security.realm.
Authorization
Policies are referenced by name from #[IsGranted]:
biscuit: policies: admin_only: 'allow if role("admin")' owner_only: 'allow if user({user_id})' scope_read: 'allow if scope({resource}, "read")'
#[IsGranted('admin_only')] public function dashboard(): Response { /* ... */ } #[IsGranted('owner_only', subject: ['user_id' => $userId])] public function profile(int $userId): Response { /* ... */ } #[IsGranted('scope_read', subject: $resource)] public function show(string $resource): Response { /* ... */ }
The voter resolves the policy and runs an authorizer over the verified token. The subject: argument is bound into the policy as parameters:
- A string subject becomes
{resource}in the policy. - An object with
getId()becomes{resource}as the string-cast id. - An associative array is bound key-by-key (use this for multi-parameter policies).
You can also pass a Datalog string directly to #[IsGranted] for ad-hoc policies that aren't worth naming:
#[IsGranted('allow if scope({resource}, "read")', subject: $resource)]
If the configured policies do not match, the voter abstains and falls back to other voters.
Authorizer Fact Templates
Policies and token checks can only reason about facts that are present in the authorizer. Token-side facts come from the token itself; request-context facts (the amount being moved, the target resource's tier, ...) must be supplied by the verifier at authorization time. authorizer_fact_templates lets you declare those facts in configuration and have BiscuitVoter inject them automatically, keyed by policy name:
biscuit: policies: credit_authorized: 'allow if role("agent"), operation("credit_wallet"), amount($a), $a <= 100000, geo({req_zone})' authorizer_fact_templates: credit_authorized: # same name as the policy facts: - 'operation("credit_wallet")' - 'amount({amount})' - 'geo({req_zone})'
#[IsGranted('credit_authorized', subject: [ 'req_zone' => $wilaya, 'amount' => $amountInDinars, ])] public function credit(): Response { /* ... */ }
Do not declare a time() fact yourself. The voter adds the current time to every authorizer it builds, so a token check such as check if time($t), $t < 2026-01-01T00:00:00Z works with no configuration. Adding a second time() fact from a template makes the engine compare an integer against a date and fail with Invalid type.
When credit_authorized is evaluated, the voter applies the same-named fact template against the subject: parameters and adds the resulting facts to the authorizer before authorize() runs; the token's own checks are evaluated against those facts too. The subject: array may carry more keys than the policy references: extra keys feed the fact template and are ignored by the policy.
A missing key denies the check. If the policy or the fact template references a parameter the subject: array does not provide, the voter votes deny, records a failed check in the profiler and logs a warning naming the policy and the error behind it. A malformed Datalog snippet in the template behaves the same way.
Authorizer\AuthorizerBuilderFactory (service biscuit.authorizer_builder_factory) does this setup, and biscuit:policy:test uses the same service. Pass the template parameters as --param and the command builds the authorizer the voter would build:
bin/console biscuit:policy:test credit_authorized -p amount=50 -p req_zone=algiers -t "$TOKEN"
Token Templates
Define reusable token shapes in configuration:
biscuit: token_templates: admin_token: facts: - 'user({id})' - 'role("admin")' scoped_reader: facts: - 'user({id})' - 'scope({resource}, "read")'
Mint tokens from templates with BiscuitTokenFactory:
use Biscuit\BiscuitBundle\Token\BiscuitTokenFactory; final class IssueTokenAction { public function __construct(private readonly BiscuitTokenFactory $factory) {} public function __invoke(int $userId, string $dog): string { $token = $this->factory->create('scoped_reader', [ 'id' => $userId, 'resource' => $dog, ]); return $token->toBase64(); } }
Block Templates and Attenuation
A holder of a Biscuit can derive a more restricted token by appending a block. Attenuation can only narrow authority; it can never widen it. The bundle exposes this through BiscuitBlockFactory, fed by reusable block templates declared in configuration:
biscuit: block_templates: read_only: checks: - 'check if operation("read")' expires: checks: - 'check if now($t), $t <= {exp}' single_resource: checks: - 'check if resource({res})'
Apply a template to an existing token:
use Biscuit\Auth\Biscuit; use Biscuit\BiscuitBundle\Token\BiscuitBlockFactory; final class ShareLinkAction { public function __construct(private readonly BiscuitBlockFactory $blockFactory) {} public function __invoke(Biscuit $parent, string $resource): Biscuit { $derived = $this->blockFactory->attenuate($parent, 'single_resource', [ 'res' => $resource, ]); return $this->blockFactory->attenuate($derived, 'expires', [ 'exp' => time() + 3600, ]); } }
For composing several templates into a single block (one extra block instead of N), use buildBlock() plus the underlying BlockBuilder::merge() before passing the result to BiscuitTokenManager::attenuate().
Every successful attenuation dispatches Biscuit\BiscuitBundle\Event\BiscuitTokenAttenuatedEvent from BiscuitTokenManager, with parent, blockSource, and child readonly properties. The bundle's data collector subscribes to it so the profiler shows the full derivation chain; you can subscribe additional listeners for audit logging or metrics.
Console Commands
| Command | Purpose |
|---|---|
biscuit:keys:generate |
Generate an ed25519 or secp256r1 key pair |
biscuit:token:create |
Mint a token from a configured template |
biscuit:token:attenuate |
Append a block to an existing token, from a template or inline Datalog |
biscuit:token:inspect |
Decode and pretty-print a Biscuit token |
biscuit:policy:test |
Run a configured policy against a token |
biscuit:revocation:revoke |
Revoke a token or a raw revocation identifier |
biscuit:revocation:check |
Check a token, exiting 0 valid, 1 revoked, 2 error |
biscuit:revocation:list |
List entries of every enumerable store |
biscuit:revocation:purge |
Drop entries whose expiration has passed |
Each command exposes --help for the full option list.
Maker
If you also have symfony/maker-bundle installed:
bin/console make:biscuit-policy ArticleViewerPolicy
This generates src/Security/Policy/ArticleViewerPolicy.php with a documented skeleton including a NAME constant, a POLICY Datalog string, and a usage example with #[IsGranted].
Token Revocation
A token is revoked when any identifier in its revocation chain is listed. Revoking a parent identifier therefore kills every token attenuated from it, which is what keeps offline attenuation from defeating revocation. Revoking the deepest identifier does the opposite: that token stops working and its ancestors keep working, so you can log one device out without invalidating every share link the user ever minted.
Identifiers come from the token structure, so you never add a jti yourself, and the list holds
no secrets. You can hand it to every service that verifies tokens.
Turning It On
Two settings are required. There is no default for on_unavailable because the right answer
depends on whether an unreachable revocation list should take your API down:
biscuit: revocation: enabled: true on_unavailable: deny stores: cache: enabled: true
denyrejects the request with a 500 when a store cannot answer. The control never silently stops working, at the cost of turning a Redis blip into an outage.allowaccepts the request, logs an error, dispatchesBiscuitRevocationDegradedEventand marks the check degraded in the profiler. Revocation stops being enforced in full while the store is down.
Enabling revocation with no store fails the container build. "Revocation is on" while every token passes would be worse than a hard failure.
Stores
Stores are consulted in priority order and the first match wins.
| Store | Priority | Writable | Notes |
|---|---|---|---|
static |
256 | no | In-memory list from config, an env var or a file. No I/O on the request path. |
in_memory |
192 | yes | Per-process list. For tests and worker runtimes. |
cache |
128 | yes | PSR-6 pool. Point it at Redis and every instance converges. |
doctrine |
64 | yes | Database table. Survives a restart, and does the slowest I/O, so it answers last. |
| yours | 0 | optional | Any service implementing RevocationStoreInterface. |
The static store is the break-glass option. It needs nothing provisioned:
biscuit: revocation: enabled: true on_unavailable: deny stores: static: ids: '%env(csv:BISCUIT_REVOKED_IDS)%' file: '%kernel.project_dir%/config/revoked_ids.txt'
The cache store gets its own pool, cache.biscuit.revocation, rather than sharing cache.app.
A routine cache:pool:clear cache.app would otherwise un-revoke every token. Set
stores.cache.pool to reuse a pool you already run.
Writing Your Own Store
Implement one method. Autoconfiguration wires it up, so the class needs no tag and no service config:
use Biscuit\BiscuitBundle\Revocation\Exception\RevocationStoreUnavailableException; use Biscuit\BiscuitBundle\Revocation\RevocationStoreInterface; final class ApiRevocationStore implements RevocationStoreInterface { public function __construct(private readonly HttpClientInterface $client) { } public function findRevoked(array $revocationIds): ?string { try { $revoked = $this->client ->request('POST', '/revocations/check', ['json' => ['ids' => $revocationIds]]) ->toArray(); } catch (ExceptionInterface $e) { throw new RevocationStoreUnavailableException($e->getMessage(), 0, $e); } foreach ($revocationIds as $revocationId) { if (\in_array($revocationId, $revoked['revoked'], true)) { return $revocationId; } } return null; } }
Resolve the whole batch in one round trip, and return the identifier as you received it so
callers can correlate it with the token. Throw RevocationStoreUnavailableException when the
backend cannot answer; that is what on_unavailable reacts to. Nothing else may escape.
RevocationChecker catches only that exception, so any other one becomes a 500 on every
authenticated request no matter what on_unavailable says. Add RevocationWriterInterface if
the store can be written to, and EnumerableRevocationStoreInterface if it can list its
entries.
Storing Revocations in a Database
Revocations kept in a database survive a restart. The cache store expires its entries and
in_memory dies with the process, so a node that comes back up starts accepting tokens that
were revoked while it was down. It needs doctrine/dbal.
biscuit: revocation: enabled: true on_unavailable: deny stores: doctrine: enabled: true connection: doctrine.dbal.default_connection table: biscuit_revoked_tokens
It reads, writes, enumerates and purges. biscuit:revocation:purge deletes rows and reports a
real count, which the cache store cannot do because a PSR-6 pool expires entries on its own.
MySQL, MariaDB, PostgreSQL and SQLite are supported. Each needs a different upsert, and the
bundle picks it from the platform. Oracle, SQL Server and DB2 fail with a named error rather
than a syntax error, so implement RevocationStoreInterface yourself on those.
The table
The bundle declares the table but never creates it. Your migrations own the DDL:
bin/console doctrine:migrations:diff # picks the table up, produces a reviewable migration
bin/console doctrine:migrations:migrate
Without migrations:
bin/console biscuit:revocation:doctrine:setup # create it bin/console biscuit:revocation:doctrine:setup --dump-sql # print the DDL instead
If you use the ORM, keep doctrine/doctrine-bundle installed. The bundle hooks
postGenerateSchema to declare the table, and that hook is what stops
doctrine:migrations:diff from generating DROP TABLE biscuit_revoked_tokens. Run that
migration and revocation is silently off: every token passes and nothing is logged.
revocation_id is the primary key, and expires_at is indexed so purging is not a full scan.
On MySQL the identifier column is pinned to utf8mb4_bin, because a default _ci collation
matches case-, accent- and trailing-space-insensitively and would revoke a token that was
never revoked.
Revoke outside your own transaction
ChainRevocationWriter writes every store in turn, and the volatile ones come first. If you
call revoke() inside a transaction that later rolls back, the cache says revoked and the
database does not. The token keeps failing until the cache entry lapses, then quietly starts
working again. Revoke after you commit, or outside the transaction.
Dates are stored in UTC regardless of your date.timezone, and sub-second precision is not
portable across drivers, so timestamps round to the second. Note that
biscuit:revocation:purge --before=2026-01-01 parses that date in PHP's default timezone, so
pass an explicit offset when it matters.
Revoking a Token
Inject RevocationWriterInterface and hand it an entry:
use Biscuit\BiscuitBundle\Revocation\RevocationEntryFactory; use Biscuit\BiscuitBundle\Revocation\RevocationWriterInterface; public function logout(RevocationEntryFactory $factory, RevocationWriterInterface $writer): void { $writer->revoke($factory->fromToken($this->currentToken, reason: 'logout')); }
fromToken() targets the deepest identifier and reads the expiration and subject from the
authority block. Use allFromToken() only when you mean to invalidate sibling tokens too.
Give every token an expiration date. Entries with no expiration can never be purged, so the
list grows forever, and biscuit:revocation:list will nag you about them.
Pushing Revocations to Other Instances
Push gives you the shortest gap between "revoked" and "rejected everywhere", which is why the Biscuit revocation guide rates a subscribe-on-startup queue the safest of the four distribution strategies. It also asks the most of your infrastructure.
If you can point stores.cache.pool at Redis, do that first. Every instance reads the same
list, nothing new to operate, and the gap is one cache lookup. Push is what you reach for when
that shortcut does not apply: worker runtimes such as FrankenPHP or RoadRunner where each
instance holds its own in_memory list and there is no shared store to converge on.
biscuit: revocation: enabled: true on_unavailable: deny stores: in_memory: enabled: true push: enabled: true
Push needs symfony/messenger. The container refuses to build without it rather than
silently doing nothing.
That config alone is not enough to propagate anything. Four more steps follow, and skipping any of them fails quietly.
Step 1: pick a transport that can fan out
Messenger hides which broker you run, but it does not hide delivery topology, and there is no
framework.messenger setting that means "broadcast". You express it in transport-specific
options, and most transports cannot express it at all:
| Transport | Can deliver to every instance |
|---|---|
| AMQP | Yes. Fanout exchange, one queue per instance |
| Redis | Yes. One consumer group per instance |
| Doctrine | No. A row is claimed by a single consumer |
| Beanstalkd | No. Work queue only |
| Amazon SQS | Not on its own. Needs SNS in front of one queue per instance, which the bridge does not manage |
| Kafka | No official bridge. Symfony points at Enqueue |
sync://, in-memory:// |
No. One process, nothing leaves it |
Routing these messages to Doctrine or Beanstalkd is the failure this feature exists to prevent:
each message goes to exactly one consumer, so one instance stops accepting the token and every
other node keeps going. Nothing errors. doctrine://default is the tempting default when you
have no broker, and it is the wrong answer here. Without AMQP or Redis, use a shared
stores.cache pool instead of push.
Step 2: give every instance its own queue
The guide's model is one queue per subscriber. A fanout exchange with a queue per instance, or Redis with a consumer group per instance.
INSTANCE_ID has to differ per running instance and stay stable across a restart, or the
instance subscribes to a fresh empty queue every boot. On Kubernetes use the pod name
(valueFrom.fieldRef.fieldPath: metadata.name); with Docker Compose replicas use the
container hostname; on bare metal use the hostname.
With AMQP:
framework: messenger: transports: biscuit_revocation: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' options: exchange: { name: biscuit_revocation, type: fanout } queues: { 'biscuit_revocation_%env(INSTANCE_ID)%': ~ }
With Redis, the consumer group is what separates the subscribers, and it needs
symfony/redis-messenger. delete_after_ack: false is required, not a tuning choice: the
default deletes each message once one group acks it, which drops it before the other
instances have read it. Cap the stream with stream_max_entries instead:
framework: messenger: transports: biscuit_revocation: dsn: '%env(REDIS_TRANSPORT_DSN)%' options: stream: biscuit_revocation group: '%env(INSTANCE_ID)%' consumer: '%env(INSTANCE_ID)%' delete_after_ack: false stream_max_entries: 10000
Redis behaves better than AMQP on restart. Symfony creates the consumer group at offset 0,
so a group reads the stream from the beginning, and a group that already exists resumes from
what it last acked. Either way a returning instance replays everything still in the stream, so
stream_max_entries is what bounds the catch-up window rather than the message being gone the
moment it was delivered. Size it for your worst expected downtime.
The cost is bookkeeping. One group per instance means retired instances leave their groups
behind, holding pending entries in Redis metadata. Prefer a stable identity that gets reused
(a StatefulSet ordinal, not a random pod name) and drop groups for instances you have retired
with XGROUP DESTROY.
Step 3: route the three messages
framework: messenger: routing: 'Biscuit\BiscuitBundle\Revocation\Message\RevokeToken': biscuit_revocation 'Biscuit\BiscuitBundle\Revocation\Message\UnrevokeToken': biscuit_revocation 'Biscuit\BiscuitBundle\Revocation\Message\PurgeExpiredRevocations': biscuit_revocation
Leave a message out of routing and Messenger handles it in-process the moment it is
dispatched. The revoking node applies it twice, harmlessly, and no other node ever hears
about it. Local testing looks fine and production propagates nothing, so route all three or
none.
Step 4: run a consumer on every instance
Nothing arrives until something reads the queue:
bin/console messenger:consume biscuit_revocation
Run it as a supervised process alongside each instance, not as one shared worker. A single
worker for the whole cluster puts the revocation list on one machine, which is the problem
push exists to solve. Give it --time-limit and let your supervisor restart it, as with any
Messenger worker.
Nothing else changes in your code. RevocationWriterInterface now resolves to a publisher
that writes locally and then broadcasts, so the commands and the logout() above start
propagating without an edit. The node doing the revoking never waits for its own message.
Checking that it works
Revoke on one instance and check from another. biscuit:revocation:check exits 0 for a
valid token and 1 for a revoked one, so this works as a shell test:
# on instance A bin/console biscuit:revocation:revoke "$TOKEN" --ttl=3600 # on instance B, a moment later bin/console biscuit:revocation:check "$TOKEN" && echo 'PUSH IS NOT WORKING'
If instance B still exits 0, the queue is the place to look. One queue shared by every
consumer is the usual cause, and messenger:stats will show a single queue draining instead
of one per instance.
A malformed message is rejected rather than written, so it follows your retry strategy and
lands in failure_transport if you configured one. Set one up: a revocation stuck in a retry
loop is a revocation that is not being enforced.
Two services keep a consumed message from being broadcast again. The publisher writes and dispatches; the handler holds a writer that only writes. A handler can't reach the bus, so there is no loop to break and no origin identifier to track. A local write that fails stops the broadcast, because telling the cluster about a revocation this node could not apply would leave it inconsistent.
The three messages carry strings only, and dates travel as RFC 3339, so they survive the JSON and AMQP serializers unchanged.
Events split along the same line. The origin node fires BiscuitTokenRevokedEvent as it
always did. Consuming nodes fire BiscuitRevocationReceivedEvent instead, so a listener
that emails "your session ended" sends one mail rather than one per node:
use Biscuit\BiscuitBundle\Event\BiscuitRevocationReceivedEvent; use Biscuit\BiscuitBundle\Revocation\RevocationPushOperation; #[AsEventListener] public function onReceived(BiscuitRevocationReceivedEvent $event): void { match ($event->operation) { RevocationPushOperation::Revoke => $this->warmCaches($event->entry), RevocationPushOperation::Unrevoke => $this->clearCaches($event->revocationId), RevocationPushOperation::Purge => $this->recordPurge($event->purged), }; }
purgeExpired() returns the count this node dropped. Counts from the other instances cannot
be collected back across a fanout. The cutoff is resolved before dispatch, so every node
purges to the same instant instead of to its own clock, which is also why
biscuit:revocation:purge reports one node's number in a cluster.
What push does not give you
With AMQP, an instance that boots after a revocation was broadcast never sees that message,
so a restart brings a node back accepting the token when in_memory is the only store. Redis
replays what is still in the stream, which narrows the gap to entries trimmed past
stream_max_entries but does not close it. Two ways to cover it, and you want one of them:
- Keep a second store behind the in-memory one. The
doctrinestore holds entries until you purge them; acachestore on a shared pool answers until they expire. - Promote long-lived revocations to the static list.
biscuit:revocation:list --format=txtwrites the exact formatstores.static.filereads, so a node has them at startup.
Revocation Commands
bin/console biscuit:revocation:revoke <token> [--all-ids] [--ttl=86400] [--dry-run] bin/console biscuit:revocation:revoke --id=<hex> --id=<hex> bin/console biscuit:revocation:check <token> [--explain] bin/console biscuit:revocation:list [--format=table|json|txt] [--subject=alice] [--expired] bin/console biscuit:revocation:purge [--before=2026-01-01T00:00:00Z] [--force]
revoke prints the whole chain with the target marked before it writes anything, and reads the
token without its signature unless you pass --verify.
check exits 0 when the token is valid, 1 when it is revoked and 2 on error, so it drops
straight into a health check.
list --format=txt emits bare newline-delimited identifiers, which is the format the static
store reads. Promoting a dynamic list to a static one is one pipe:
bin/console biscuit:revocation:list --format=txt > config/revoked_ids.txt
Events
BiscuitRevocationCheckedEventcarries the fullRevocationResult. Checks run on every authenticated request, so it only fires for revoked tokens by default. Setdispatch_check_eventstoalwaysorneverto change that.BiscuitTokenRevokedEventfires when an entry is written locally, on the node doing the writing.BiscuitRevocationDegradedEventfires when a store fails, under either failure policy, so alerting works whether the request was rejected or let through.BiscuitRevocationReceivedEventfires on a node that applied a change pushed from another instance. See Pushing Revocations to Other Instances.
Where the Check Sits
The authenticator checks revocation after signature verification, never before. Revocation
identifiers are derived from the block signatures, so until parse() succeeds an attacker
controls them. Checking earlier would let anonymous requests drive lookups with arbitrary keys
into Redis or SQL, and would report a forged token as revoked rather than invalid.
Web Profiler Integration
When symfony/web-profiler-bundle is installed in the dev environment, the bundle adds a Biscuit panel showing:
- Whether a token was attached to the request, with block count and revocation IDs
- All blocks in the token (Datalog source)
- Every policy check performed during the request, with parameters and pass/fail outcome
- Every attenuation performed during the request, with parent and child revocation IDs and the appended block source
- The revocation verdict: which store answered, how long each store took, and whether the check ran degraded
The panel is populated for rejected requests too, so a revoked token still shows its blocks and the store that matched. The toolbar turns red when a token is revoked and yellow when a store could not answer.
Testing Helpers
The Biscuit\BiscuitBundle\Test namespace provides utilities for functional tests.
BiscuitTestTrait mints tokens against a per-test-class key pair without needing to mock anything:
use Biscuit\BiscuitBundle\Test\BiscuitTestTrait; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; final class ProtectedEndpointTest extends WebTestCase { use BiscuitTestTrait; public function testAdminCanAccess(): void { $token = $this->createTestTokenBase64('user(1); role("admin")'); $client = static::createClient(); $client->request('GET', '/api/admin', server: [ 'HTTP_AUTHORIZATION' => 'Bearer ' . $token, ]); self::assertResponseIsSuccessful(); } }
TestBiscuitAuthenticator is a drop-in replacement for the production authenticator that trusts tokens signed by the test key pair.
BiscuitFixtures and BiscuitFixtureLoader load Datalog scenarios from YAML files for repeatable fixture data.
Testing Revocation
BiscuitRevocationTestTrait answers the question a revoked token actually raises: how does my
endpoint behave? assertTokenRevoked() and assertTokenNotRevoked() work against whichever
stores you configured, with no knowledge of which one answered.
receiveRevocation() applies a revocation the way a consuming node does, so you can test the
case that is otherwise hard to reach: another instance revoked this token and yours has not
handled the message through a broker. It writes locally and publishes nothing, which is exactly
what the push handler does. It takes a Biscuit, an UnverifiedBiscuit, a RevocationEntry or
a raw identifier, and a token is revoked by its deepest identifier, matching
RevocationEntryFactory::fromToken().
use Biscuit\BiscuitBundle\Test\BiscuitRevocationTestTrait; use Biscuit\BiscuitBundle\Test\BiscuitTestTrait; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; final class LogoutTest extends WebTestCase { use BiscuitRevocationTestTrait; use BiscuitTestTrait; public function testAnotherInstanceRevokedTheToken(): void { $client = static::createClient(); $token = $this->createTestToken('user("alice")'); $this->receiveRevocation($token, reason: 'logout'); $this->assertTokenRevoked($token); $client->request('GET', '/api/me', server: [ 'HTTP_AUTHORIZATION' => 'Bearer ' . $token->toBase64(), ]); self::assertResponseStatusCodeSame(401); } }
The assertions need biscuit.revocation.enabled. The receive helpers also need
biscuit.revocation.push.enabled, and say so if it is off rather than failing obscurely.
To assert the other direction, that your code published a revocation, route the messages to
in-memory:// in the test environment and read the transport. No bundle helper is involved:
# config/packages/test/messenger.yaml framework: messenger: transports: biscuit_revocation: 'in-memory://'
$this->writer->revoke($this->entryFactory->fromToken($token, reason: 'logout')); $transport = static::getContainer()->get('messenger.transport.biscuit_revocation'); self::assertInstanceOf(RevokeToken::class, $transport->getSent()[0]->getMessage());
One thing no test can tell you: whether your transport really fans out to every instance. That
is a property of your broker topology, not of your code, so verify it against a running cluster
with the biscuit:revocation:check recipe above.
Development
Run the full quality gate locally:
composer check
This runs php-cs-fixer (dry run), phpstan at level 8, and the PHPUnit suite.
To auto-fix style issues:
composer cs-fix
Contributing
See CONTRIBUTING.md. All contributors are expected to follow the Code of Conduct.
Security
Vulnerabilities should be reported to security@biscuitsec.org. See SECURITY.md for details.
License
Apache License 2.0. See LICENSE.