medzuch / jwt-bundle
Symfony bundle wiring medzuch/jwt-php into Symfony Security: issue and verify RFC 9068 access tokens, OIDC ID tokens and custom JWS/JWE. PHP 8.3 and 8.4, Symfony 6.4, 7.4 and 8.x.
Package info
Type:symfony-bundle
pkg:composer/medzuch/jwt-bundle
Requires
- php: ~8.3.0 || ~8.4.0
- medzuch/jwt-php: ^1.2
- psr/clock: ^1.0
- symfony/config: ^6.4 || ^7.4 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.4 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.4 || ^8.0
- symfony/security-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/yaml: ^6.4 || ^7.4 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.0
- psr/log: ^3.0
- roave/security-advisories: dev-latest
- symfony/browser-kit: ^6.4 || ^7.4 || ^8.0
- symfony/error-handler: ^6.4 || ^7.4 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/routing: ^6.4 || ^7.4 || ^8.0
This package is auto-updated.
Last update: 2026-08-20 06:37:21 UTC
README
A Symfony bundle wiring medzuch/jwt-php into Symfony
applications: issuing JOSE tokens (RFC 9068 access tokens, OIDC ID tokens, custom JWS/JWE)
and verifying them through Symfony's native Security stack — the access_token firewall
authenticator, DI, configuration, console and profiler.
Works for any of these roles, in any combination:
- Resource server — verify bearer tokens on an API firewall.
- Authorization server — mint short-lived access tokens on login.
- OIDC relying party — verify a third-party IdP's tokens via cached, rotation-aware JWKS.
- Service-to-service — machine tokens between your own services.
Status: v0.1.0, pre-1.0. The MVP works end to end — issue a token on login, verify it on a firewall, be authenticated — but nothing about it is stable yet, and only HMAC keys exist. Asymmetric keys, rotation and JWKS arrive in the next phase; see
docs/plan.mdfor the full design and roadmap.
Requires PHP 8.3 / 8.4 and Symfony 6.4 LTS, 7.4 LTS or 8.x.
Installation
The package is not on Packagist yet and has no tagged release, so point Composer at the
repository and ask for the development branch by name — a plain composer require finds no
stable version to install.
Add the repository to your composer.json:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/medzuch/jwt-bundle" }
]
}
Then:
composer require medzuch/jwt-bundle:dev-develop
Without Symfony Flex, register the bundle yourself in config/bundles.php:
return [ // ... Medzuch\JwtBundle\MedzuchJwtBundle::class => ['all' => true], ];
Quickstart
Verifying tokens — a resource server
You have an API and tokens minted somewhere else (another service, an identity provider, or this same application). Configure a key to verify with and a consumer describing what a token must say:
# config/packages/medzuch_jwt.yaml medzuch_jwt: keys: default: hmac: '%env(JWT_SECRET)%' consumers: api: issuer: '%env(APP_URL)%' audience: '%env(APP_URL)%' keys: [default] allowed_algorithms: [HS256]
Then point a firewall at the consumer's handler:
# config/packages/security.yaml security: firewalls: api: pattern: ^/api stateless: true access_token: token_handler: medzuch_jwt.handler.api
That is the whole integration. Symfony's own access_token authenticator extracts the bearer
token, the handler validates it and hands back the sub claim, and your user provider loads the
user — so authorization keeps reading current state from your database instead of from a token
minted minutes ago.
A token is accepted only if it is signed by a configured key, uses an allowed algorithm, names the expected issuer and audience, has not expired, and carries the RFC 9068 claim set. Anything else is a 401; the reason goes to the log, never to the client.
Issuing tokens — an authorization server
Add an issuer, pointing at the key that signs:
# config/packages/medzuch_jwt.yaml medzuch_jwt: keys: default: hmac: '%env(JWT_SECRET)%' issuers: default: issuer: '%env(APP_URL)%' key: default client_id: '%env(APP_CLIENT_ID)%' audience: '%env(APP_URL)%' ttl: 900
The signing algorithm is not configured here: a key is bound to exactly one algorithm, so naming it twice could only ever disagree.
Wire the login response and you are done:
# config/packages/security.yaml security: firewalls: login: pattern: ^/login stateless: true json_login: check_path: /login success_handler: medzuch_jwt.login.default
A successful login now answers with RFC 6750 fields, under Cache-Control: no-store:
{ "access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJIUzI1NiJ9...", "token_type": "Bearer", "expires_in": 900 }
To mint a token yourself — a service account, a token for one specific audience — inject the issuer:
use Medzuch\JwtBundle\Issuer\AccessTokenIssuer; public function __construct(private readonly AccessTokenIssuer $issuer) {} public function mint(): string { $token = $this->issuer->issue( subject: 'user-42', scopes: ['invoices:read'], claims: ['tenant' => 'acme'], ttl: 60, audience: ['https://reports.example.com'], ); return $token->value; // $token->expiresIn is what it was actually minted with }
Every argument after subject is optional and narrows what configuration already decided.
Both at once
An application that issues its own tokens and verifies them on its own API needs one key, one
issuer and one consumer that agree on issuer and audience:
logger names any PSR-3 service. The id below assumes MonologBundle with a jwt channel
declared (monolog: channels: [jwt]); without one, the container will not build. Omit the line
to disable logging entirely.
# config/packages/medzuch_jwt.yaml medzuch_jwt: logger: 'monolog.logger.jwt' keys: default: hmac: '%env(JWT_SECRET)%' issuers: default: issuer: '%env(APP_URL)%' key: default client_id: '%env(APP_CLIENT_ID)%' audience: '%env(APP_URL)%' consumers: api: issuer: '%env(APP_URL)%' audience: '%env(APP_URL)%' keys: [default] allowed_algorithms: [HS256]
Keys
The MVP takes HMAC secrets from the environment:
medzuch_jwt: keys: default: hmac: '%env(JWT_SECRET)%' # or %env(base64:JWT_SECRET)% for a base64 secret algorithm: HS256 # HS256 | HS384 | HS512 kid: ~ # required once two keys share an algorithm
Generate one with at least 32 bytes of entropy (48 for HS384, 64 for HS512 — RFC 8725 §3.5):
php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'
That prints base64, so decode it on the way in with Symfony's base64: processor — the two go
together:
medzuch_jwt: keys: default: hmac: '%env(base64:JWT_SECRET)%'
Wiring a base64 string as %env(JWT_SECRET)% also works and is not weaker, but then the key
material is the encoded text rather than the bytes you generated, which makes the length rules
above harder to reason about.
The secret stays an environment reference all the way into the key, so it never becomes a
container parameter and never appears in debug:container output. The flip side is that its
length cannot be checked when the container is built: too short a secret fails when the key is
first used, not at deploy time.
Configuration reference
The complete tree, with every option, default and explanation, is generated from the bundle itself:
bin/console config:dump-reference medzuch_jwt
That output is always accurate for the version you have installed, which a hand-written reference in this file would not be.
Mistakes it refuses to boot with
Configuration errors fail when the container is built, naming the key at fault, rather than looking like rejected tokens at runtime:
- a consumer or issuer naming a key that does not exist
- an allowed algorithm with no key behind it — a token using it could never be verified
- two keys a token cannot tell apart: sharing a
kid, or sharing an algorithm with nokid - a static claim named
iss,sub,aud,exp,nbf,iatorjti— those are set from configuration or by the profile - a YAML map where a sequence is expected, an unknown algorithm name, leeway above the library's ceiling
What it deliberately does not do
Refresh-token storage and rotation, user entities and login forms, OAuth 2.0 authorization-server
machinery (consent, grants, PKCE), and session-based authentication are all outside this package.
Section 8 of docs/plan.md explains why for each.
Documentation
docs/plan.md— the design, the feature catalogue with priority tiers, the recorded decisions, and the roadmap.CHANGELOG.md— what has landed so far.CONTRIBUTING.md— how to work on it.SECURITY.md— how to report a vulnerability. Not through a public issue.
License
MIT — see LICENSE.