Skip to main content

xtr-security-jwt

Self-issued JSON Web Tokens for xtr security: an encoder, a token manager and a firewall authenticator.

python 3.11+ typed license MIT

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("file: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, but the JWT bundle is an add-on that cannot sign a token without a key: the build fails with an InvalidConfigurationError naming the missing setting and the <app>/config/jwt.py @configure function when no secret_key is configured.

Field Default What it is
secret_key None The private key or shared secret that signs, as the key text or a file path. Required
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
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(provider=None, authenticator=None) — an optional user-provider override and an optional registered authenticator service to build instead of the default. 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

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.

Events

Every event carries a name constant on Events, so a listener names the constant rather than importing the event class:

Event Name Dispatched
JwtCreatedEvent Events.JWT_CREATED Before a token is signed; a listener may shape the claims and header
JwtEncodedEvent Events.JWT_ENCODED After a token is signed
JwtDecodedEvent Events.JWT_DECODED After a token verifies; a listener may reject it
JwtAuthenticatedEvent Events.JWT_AUTHENTICATED After a token authenticated a request
JwtExpiredEvent Events.JWT_EXPIRED When an expired token is refused
JwtInvalidEvent Events.JWT_INVALID When a bad token is refused
JwtNotFoundEvent Events.JWT_NOT_FOUND When a request carried no token

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

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:sync does the Activate, Configure, Environment and Ignore steps below: it lists JwtBundle, writes a starting <app>/config/jwt.py, JWT_SECRET_KEY_PATH (commented out) in .env, and ignores /secrets/*.pem. It prints the keypair and firewall steps, which a recipe cannot make for you.
  • Activate — JwtBundle: {"all": True} in BUNDLES in <app>/bundles.py, imported from xtr_security_jwt.bundle. Then build the kernel with concurrent_scoped_access=True and call setup(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 — the bundle needs a signing key: a <app>/config/jwt.py @configure function returning a JwtConfig(secret_key=…), and a firewall that lists JwtAuthenticatorConfig() under its authenticators — see Configure and Kernel / bundle. Without a secret_key the build fails, naming the missing setting.
  • Environment — whatever the key material reads: an application usually points the key at a file with env("file:JWT_SECRET_KEY_PATH"), so JWT_SECRET_KEY_PATH must be set.
  • Ignore — the private key files the keypair command writes, when --output-dir points into the project: add secrets/*.pem (or wherever they land) to .gitignore.
  • Remove — drop the BUNDLES entry, delete <app>/config/jwt.py and the JwtAuthenticatorConfig from the firewall, then uv remove xtr-security-jwt.
  • Check — debug:bundles shows jwt as listed and active, and security as required; debug:firewall api lists the firewall's jwt authenticator.

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 until a service is asked for — but, needing a signing key the application must choose, it fails the build when none is configured.

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
MissingClaimError JwtFailureError A token lacks a claim a caller required
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/
├── events.py                      the event name constants
├── 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 the signing and verifying 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 3.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xtr-security-jwt 3.0.0
File Size Uploaded
xtr_security_jwt-3.0.0.tar.gz 50.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for xtr-security-jwt 3.0.0
File Interpreter ABI Platform
xtr_security_jwt-3.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 142.9 kB

Release files / xtr_security_jwt-3.0.0.tar.gz

Download URL xtr_security_jwt-3.0.0.tar.gz
Size 50.5 kB
Tags Source
SHA-256 checksum
How to use checksums
04dd16abaaa055a55cceaeb9a7cbc2fcae6fbd7e6e6c038341773e49075839d1
BLAKE2b-256 checksum
How to use checksums
87c996a26284e9f30c4f19bf7f852a84038e76b3174e3d6001b7c8b290b42432
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 5, 2026.

Transparency log

Release files / xtr_security_jwt-3.0.0-py3-none-any.whl

Download URL xtr_security_jwt-3.0.0-py3-none-any.whl
Size 92.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9a20a0139874c6694c996f0f5b9f7bd4b093745eed9b781e6393510cd9e63f5c
BLAKE2b-256 checksum
How to use checksums
2b0ed8fc0826ba5cf841c7a003c00561e38268ada97ac7734d8ebe9e9437c9db
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 5, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

3.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page