xtr-security-jwt
Self-issued JSON Web Tokens for xtr security: an encoder, a token manager and a firewall authenticator.
Why?
The security family verifies bearer tokens issued elsewhere, but issues none of its own. This package is the self-issued-token layer on top of it: an application signs a JSON Web Token for one of its users, hands it to a client, and accepts it back on a protected route — the whole loop, with no authorization server.
It ships everything for both halves of that loop:
- 🔑 A key loader — a signing key given as text or a file path, an optional pass phrase, a public key derived from the private one when absent, and extra public keys other issuers are trusted by.
- ✍️ An encoder and a token manager — the encoder signs and verifies claims through a JWS provider; the token manager assembles the user's roles and identity, announces the claims for a listener to shape, signs them, and never reads any of the token from client input.
- 🔥 A firewall key
jwt— one line in a firewall accepts self-issued tokens; the bundle wires the authenticator, the extractors and the user provider through the security family's seams. - 🪪 A stateless user — a user rebuilt from a token's own claims, so a deployment that keeps no user store still authenticates a request by the token alone.
- 🛠️ Commands — mint a signing key pair, mint a token for a user, or check that the configured keys sign and verify.
Install
uv add xtr-security-jwt # the library and its JwtBundle
uv add "xtr-security-jwt[console]" # + jwt:generate-keypair, jwt:generate-token, jwt:check-config
Requires Python 3.11+. The package ships a bundle, so it depends on the container and the security bundle it wires into, alongside the security family's core and http edge and the event dispatcher and clock — all come with it.
Quick start
1. Mint a signing key
$ jwt:generate-keypair --algorithm RS256 --output-dir secrets/
Wrote secrets/8f2c….pem and secrets/8f2c….jwks.json
The private PEM signs; the public JWK set verifies — publish it, keep the PEM secret.
2. Configure the signing key and a firewall
# app/config/jwt.py
from xtr_dependency_injection import configure, env
from xtr_security_jwt.bundle import JwtConfig
@configure
def jwt() -> JwtConfig:
return JwtConfig(
secret_key=env("resolve:JWT_SECRET_KEY_PATH"),
user_id_claim="username",
)
# app/config/security.py
from xtr_dependency_injection import configure
from xtr_security.bundle import (
AccessControlConfig,
FirewallConfig,
SecurityConfig,
)
from xtr_security_jwt.bundle import JwtAuthenticatorConfig, JwtUserProviderConfig
@configure
def security() -> SecurityConfig:
return SecurityConfig(
providers={"jwt_users": JwtUserProviderConfig()},
firewalls={
"api": FirewallConfig(
pattern=r"^/api",
provider="jwt_users",
authenticators=(JwtAuthenticatorConfig(),),
),
},
access_control=(AccessControlConfig(path=r"^/api", attribute="IS_AUTHENTICATED"),),
)
JwtAuthenticatorConfig() is the whole of what a firewall needs to accept self-issued tokens:
the keys, the algorithm and the extractors all come from the JWT bundle's own configuration.
JwtUserProviderConfig() is a stateless provider that rebuilds the user from the token's claims,
for a deployment that keeps no user store; a firewall with its own user provider names that
instead.
3. Protect the routes and write a /token endpoint
# app/web.py
from typing import Annotated
from fastapi import FastAPI
from xtr_dependency_injection import Injected, Kernel
from xtr_http_kernel import setup
from xtr_security_core.user.user_interface import UserInterface
from xtr_security_http import CurrentUser, Firewall
from xtr_security_jwt import JwtTokenManagerInterface
from app.bundles import BUNDLES
app = FastAPI(dependencies=[Firewall()])
@app.post("/token")
async def issue_token(
identifier: str,
tokens: Injected[JwtTokenManagerInterface],
) -> dict[str, str]:
user = await _authenticated_user(identifier) # the application checks the password
return {"access_token": await tokens.create(user)}
@app.get("/api/me")
async def me(user: Annotated[UserInterface, CurrentUser()]) -> dict[str, str]:
return {"user": user.get_user_identifier()}
kernel = Kernel("app", concurrent_scoped_access=True)
setup(app, kernel)
The token manager assembles the registered claims and signs them; it never reads any of the
token from client input. The application authenticates the user before calling create, the
FastAPI-tutorial way — burning a dummy hash for an unknown user, and throttling the endpoint
with a RateLimited dependency.
$ curl -i localhost:8000/api/me
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
{"code":401,"message":"JWT Token not found"}
$ TOKEN=$(curl -s -X POST 'localhost:8000/token?identifier=ada' | jq -r .access_token)
$ curl -i -H "Authorization: Bearer $TOKEN" localhost:8000/api/me
HTTP/1.1 200 OK
{"user":"ada"}
Configure
JwtConfig is a frozen dataclass buildable with no arguments, and the bundle builds and boots
on it. No key is read until a token is signed or verified, so an application with no key yet
still runs — its console included, which is how jwt:generate-keypair mints the key it lacks.
The first token signed or verified without one raises MissingKeyError, naming the setting to
fill in and the command that mints a key; jwt:check-config asks the same question before a
request does, so a deploy can run it as a gate. A key is never minted on the application's
behalf: in production a deployment mounts its own and points secret_key at it.
The key is measured where it is read, too: an HMAC secret shorter than its algorithm requires is
refused the first time it signs, with an InvalidConfigurationError naming the floor; an
issuer=env(…) resolving to nothing is refused where the token manager is built.
| Field | Default | What it is |
|---|---|---|
secret_key |
None |
The private key or shared secret that signs, as the key text or a file path. Nothing signs until it names a key |
issuer |
"%kernel.name%" |
The iss every token is stamped with and checked against: the kernel's name unless set |
audience |
() |
Audiences a token is minted for; when set, a presented token must name at least one. Always a tuple — audience="api" is refused, write audience=("api",) |
public_key |
None |
The public key that verifies, or None to derive it from the private key |
additional_public_keys |
() |
Files of extra public keys a token may also be verified against, each named after the key id it holds |
pass_phrase |
"" |
The pass phrase the private key is encrypted with |
token_ttl |
3600 |
How long a minted token lives, in seconds |
allow_no_expiration |
False |
Whether a token with no expiry is honoured |
clock_skew |
0 |
Seconds of clock skew tolerated on a verified token's time claims |
encoder |
EncoderConfig() |
The signature algorithm (RS256 by default) and an optional encoder service override |
user_id_claim |
"username" |
The claim the user's identifier is written into and read back from |
token_extractors |
TokenExtractorsConfig() |
Where a firewall reads a token from |
A token extractor is one of four, told apart by where a token travels — the authorization header is on by default, the others opt in:
| Extractor | Default | Reads |
|---|---|---|
authorization_header |
on, Bearer / Authorization |
A token from a header, after a scheme prefix |
cookie |
off, BEARER |
A token from a named cookie |
query_parameter |
off, bearer |
A token from a named query parameter |
split_cookie |
off | A token split across several named cookies, rejoined |
A firewall authenticator is JwtAuthenticatorConfig() — it takes nothing: the keys, the
algorithm and the extractors all come from the JWT bundle's own configuration, and the user is
loaded through the firewall's own provider. A stateless user provider is
JwtUserProviderConfig(user_class=JwtUser) — the class the provider rebuilds from a token's
claims.
Services
JwtBundle registers, from the signing key down:
| Service | Interface | What it does |
|---|---|---|
RawKeyLoader |
KeyLoaderInterface |
Reads the signing and verifying key material |
JoserfcJwsProvider |
JwsProviderInterface |
Signs a payload and verifies a token with joserfc |
DefaultJwtEncoder |
JwtEncoderInterface |
Maps the provider's outcome to encode/decode failures |
JwtManager |
JwtTokenManagerInterface |
Assembles, announces and signs a token, reads one back |
Every verifying key is imported once and filed under both ids it is known by — the one its source
gives it, the kid a JWK or a JWK set carries or the name of the file it was read from, since
jwt:generate-keypair writes <kid>.pem and <kid>.jwks.json; and its RFC 7638 thumbprint, the
name public_key_set publishes it under. So a presented token naming a kid is verified in a
single attempt whichever of the two it carries, and a deployment rotating keys keeps accepting
tokens signed by the keys it still trusts. An extra key file that cannot be read as a key of the
configured algorithm's type is refused by name rather than quietly dropped.
The token manager gathers every registered PayloadEnrichmentInterface into a chain, so a
deployment stamps a claim onto every token by registering an enrichment — a RandomJtiEnrichment
for a unique id — never by reaching for the container.
Beside those services, xtr_security_jwt.services.key_loader exposes the key layer as plain
functions, for a package or an endpoint that mints and publishes keys without a container:
| Function | Returns |
|---|---|
generate_signing_key(algorithm, key_id=None) |
The private key algorithm signs with — RS*/PS* an RSA-2048 key, ES* the curve the algorithm names, EdDSA an Ed25519 key. What jwt:generate-keypair mints |
public_key_set(key_loader, algorithm) |
{"keys": [...]} — the signing key's public half, then every additional public key, each with its public members alone |
signing_key_id(key_loader, algorithm) |
The id the signing key is published under, for a token's kid header |
A key is named by the kid its own material carries or, since a lone PEM carries none, by its
RFC 7638 thumbprint — which is derived from the key's public members, so it is the same taken
from either half. The kid a signer stamps into a header and the one the published set announces
therefore agree with nothing passed between them. A shared secret is never published: both
functions refuse an HS* algorithm rather than hand out, or quietly omit, the key that signs.
Each reads the key material as it is called, so a deployment serving the set at an endpoint
caches the document rather than rebuilding it per request.
Events
Every event is dispatched as itself, so a listener subscribes to the class it reads — the same way the rest of the security family does:
| Event | Dispatched |
|---|---|
JwtCreatedEvent |
Before a token is signed; a listener may shape the claims and header |
JwtEncodedEvent |
After a token is signed |
JwtDecodedEvent |
After a token verifies; a listener may reject it |
JwtAuthenticatedEvent |
After a token authenticated a request |
JwtExpiredEvent |
When an expired token is refused |
JwtInvalidEvent |
When a bad token is refused |
JwtNotFoundEvent |
When a request carried no token |
Those seven are everything this package dispatches. The three refusals derive from
AuthenticationFailureEvent, which is never dispatched on its own, so a listener that handles
any refusal subscribes to that base — or to the JwtFailureEventInterface it implements —
rather than to all three.
Commands
With the console extra and a console bundle active:
| Command | Does |
|---|---|
jwt:generate-keypair [--algorithm RS256|ES256|EdDSA] [--kid KID] [--output-dir DIR] |
Mints a signing key and prints its private PEM and public JWK set — or writes them to DIR, named by the key id |
jwt:generate-token IDENTIFIER [--provider NAME] |
Loads the user by identifier through a configured user provider and prints a token signed for it; --provider picks between several |
jwt:check-config |
Signs a probe token and reads it back, proving the configured keys sign and verify |
jwt:generate-keypair and jwt:generate-token print a private key and a usable access token to
standard output: run them only where the terminal, its scrollback and any shell history are
trusted, and prefer --output-dir for the key pair.
Writing with --output-dir leaves the private PEM 0o600, the public JWK set 0o644, and every
directory the command had to create 0o700; a directory that already exists keeps the mode it
has. A key file already in place is not replaced unless --force is given, and a key is never
written through a symbolic link.
Use in an application
Everything adding this package to an application on xtr-dependency-injection takes — and, read backwards, what removing it undoes.
- Install —
uv add xtr-security-jwt; add[console]for the commands. - Recipe —
uv run xtr-recipes recipes:syncdoes the Activate, Configure, Environment and Ignore steps below: it listsJwtBundle, writes a starting<app>/config/jwt.py,JWT_SECRET_KEY_PATH=%kernel.project_dir%/secrets/jwt.pemin.env, and adds/secrets/*.pemand/secrets/*.jwks.jsonto.gitignore. It prints the keypair and firewall steps, which a recipe cannot make for you. - Activate —
JwtBundle: {"all": True}inBUNDLESin<app>/bundles.py, imported fromxtr_security_jwt.bundle. Then build the kernel withconcurrent_scoped_access=Trueand callsetup(app, kernel)where the application is served, as the security family requires. - Brings along — the security bundle, the clock and the event dispatcher always, because this bundle requires them; the console bundle when xtr-console is installed, for the commands.
- Configure — with no configuration the bundle builds and boots, the issuer is the kernel's
name, and nothing signs until a key is named: a
<app>/config/jwt.py@configurefunction returning aJwtConfig(secret_key=…), and a firewall that listsJwtAuthenticatorConfig()under itsauthenticators— see Configure and Kernel / bundle. Mint a development key withjwt:generate-keypair --kid jwt --output-dir secrets; a deployment mounts its own. - Environment —
JWT_SECRET_KEY_PATH, the key file the writtenconfig/jwt.pyreads withenv("resolve:JWT_SECRET_KEY_PATH"); until the file exists, signing raisesMissingKeyError. - Ignore — the key files the keypair command writes, when
--output-dirpoints into the project: the recipe adds/secrets/*.pemand/secrets/*.jwks.jsonto.gitignore(or add wherever they land). - Remove — drop the
BUNDLESentry, delete<app>/config/jwt.pyand theJwtAuthenticatorConfigfrom the firewall, thenuv remove xtr-security-jwt. - Check —
debug:bundlesshowsjwtaslistedandactive, andsecurityasrequired;debug:firewall apilists the firewall'sjwtauthenticator;jwt:check-configexits0once the key is there and signs and verifies.
Kernel / bundle
# app/bundles.py
from xtr_security_jwt.bundle import JwtBundle
BUNDLES = {JwtBundle: {"all": True}}
JwtBundle registers the signing chain — the key loader, the JWS provider, the encoder and the
token manager — under their interfaces, and prepends onto the security configuration an
authenticator factory keyed jwt and a user-provider factory keyed jwt. A firewall's
JwtAuthenticatorConfig then builds a JwtAuthenticator over the token manager, the main event
dispatcher, the configured token extractors and the firewall's user provider. It
requires the security, clock and
event dispatcher bundles, and the console bundle
when it is installed. It touches no cryptography and reads no key until a token is signed or
verified, so it builds and boots with no configuration.
Errors
Everything the package raises derives from SecurityError (from
xtr-security-core), so one except SecurityError catches it all:
| Error | Base | Raised when |
|---|---|---|
JwtFailureError |
SecurityError |
The base of the signing and verification failures; carries a reason and any decoded payload |
JwtEncodeFailureError |
JwtFailureError |
A token could not be signed — invalid_config or unsigned_token |
JwtDecodeFailureError |
JwtFailureError |
A token could not be read — invalid_token, expired_token or unverified_token |
MissingKeyError |
InvalidArgumentError |
No key is configured, or the key file it names is not there. A deployment's fault, not a token's: it passes through the encoder, so a request meeting it is a server error rather than a 401 |
ExpiredTokenError |
AuthenticationError |
A caller presented an expired token — answered with a 401 "Expired JWT Token" |
InvalidTokenError |
AuthenticationError |
A caller presented a malformed, unsigned or tampered token — 401 "Invalid JWT Token" |
MissingTokenError |
AuthenticationError |
A request reached a protected resource with no token — 401 "JWT Token not found" |
InvalidPayloadError |
AuthenticationError |
A verified token carried no user-id claim |
Layout
xtr_security_jwt/
├── encoder/ the encoder interfaces and the default encoder
├── services/
│ ├── jwt_manager.py assembles, announces and signs a token
│ ├── jws_provider/ signs and verifies with joserfc
│ ├── key_loader/ reads, mints and publishes the key material
│ └── payload_enrichment/ claims added to every token
├── signature/ the created- and loaded-token value objects
├── security/
│ ├── authenticator/ the JwtAuthenticator and its token
│ └── user/ the stateless JwtUser and its provider
├── token_extractor/ where a firewall reads a token from
├── event/ the events dispatched around a token's life
├── exception/ the errors, one family under one base
├── response/ the failure response
├── command/ jwt:generate-keypair, jwt:generate-token, jwt:check-config
├── factory/ the jwt authenticator factory
├── user_provider/ the jwt user-provider factory
└── bundle/ JwtBundle and its configuration
Development
Developed in the python-xtr monorepo, under
packages/xtr-security-jwt; run the commands below from there. The
python-xtr-security-jwt repository is a read-only copy, so send issues and pull requests to
the monorepo.
uv sync --all-packages --all-extras
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest --cov
License
MIT — see LICENSE.
Metadata
Release files for xtr-security-jwt 4.0.4
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xtr_security_jwt-4.0.4.tar.gz | 74.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xtr_security_jwt-4.0.4-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 184.7 kB
Release files / xtr_security_jwt-4.0.4.tar.gz
| Download URL | xtr_security_jwt-4.0.4.tar.gz |
|---|---|
| Size | 74.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d1238a830c82e284af285a55c07be2db2cf7419e4cffbdb475387b7d5ec8d1f5
|
|
BLAKE2b-256 checksum How to use checksums |
872d7e0d09a618a9bc865d19718e326a71d5ae7fea7b78e7d0661766bb12c332
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logRelease files / xtr_security_jwt-4.0.4-py3-none-any.whl
| Download URL | xtr_security_jwt-4.0.4-py3-none-any.whl |
|---|---|
| Size | 110.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fe4f46d8e64a21a869d261fc26588c705bda57ef3dd79552d13303d218969be9
|
|
BLAKE2b-256 checksum How to use checksums |
43a3604dffe10682a4e1931f506b31a5c0c33f2912c61f3a28c0e52ad4e70ef8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency log