shared-lib
A production-ready JWT authentication library for issuing and verifying access and refresh tokens across microservices, built on PyJWT and managed with uv.
Features
- Access token + refresh token issuance, individually or as a pair
- Token verification with signature, expiry, not-before, issuer, and audience checks
- Strict token-type separation - a refresh token can never be used where an access token is expected, and vice versa
- Refresh token rotation (
refresh_access_token(..., rotate_refresh_token=True)) - Pluggable revocation - pass an
is_revoked(jti) -> boolcallback backed by whatever store you use (Redis, a database, ...) to reject tokens by ID before they expire - Asymmetric (RS256/ES256/PS256) and symmetric (HS256) algorithm support, configured entirely via environment variables
- Typed dataclasses (
TokenPair,TokenPayload) and a small, specific exception hierarchy instead of stringly-typed errors - Full unittest suite and GitHub Actions CI
Why RS256 for microservices
With a symmetric algorithm (HS256), every service that needs to verify a token must hold the exact same secret used to sign it - so the secret has to be distributed to every service, and any one of them leaking it lets an attacker forge tokens for the whole system.
With an asymmetric algorithm (RS256), only the service that issues tokens (e.g. an auth service) holds the private key. Every other service is configured with just the public key, which is enough to verify a token's signature but not to create new ones. This is the recommended default for a microservices setup and is what this library uses out of the box.
Installation
This repo is managed with uv. From the project root:
uv sync
To use jwt_auth from another project in this workspace/monorepo, add it as a path or git dependency with uv add.
Quickstart
-
Copy the example environment file and generate a dev key pair:
cp .env.example .env uv run python scripts/generate_keys.py
This writes
keys/private_key.pemandkeys/public_key.pem(both gitignored). The default.envalready pointsJWT_PRIVATE_KEY_PATH/JWT_PUBLIC_KEY_PATHat these files. -
Run the demo:
uv run main.py
-
Use it in code:
from jwt_auth import JWTManager, TokenExpiredError, InvalidTokenError manager = JWTManager() # reads configuration from the environment tokens = manager.create_token_pair(subject="user-123", extra_claims={"role": "trainer"}) # tokens.access_token, tokens.refresh_token, tokens.expires_in try: payload = manager.verify_access_token(tokens.access_token) user_id = payload.sub except TokenExpiredError: ... # ask the client to hit the refresh endpoint except InvalidTokenError: ... # reject the request, log the attempt
-
Refreshing an access token:
new_tokens = manager.refresh_access_token(refresh_token, rotate_refresh_token=True)
Auth service vs. downstream services
Because RS256 keys are asymmetric, a downstream service that only ever needs to verify tokens should be configured with just the public key - it will raise ConfigurationError if you try to issue a token with it:
# Auth service - has both keys, can issue and verify.
issuer = JWTManager() # JWT_PRIVATE_KEY_PATH and JWT_PUBLIC_KEY_PATH set
# Downstream service - only distribute the public key.
verifier = JWTManager(JWTSettings(algorithm="RS256", public_key=public_key_pem))
verifier.verify_access_token(incoming_token) # OK
verifier.create_access_token("user-1") # raises ConfigurationError
Revocation
This library doesn't ship a storage backend, since that choice (Redis, Postgres, ...) belongs to the application. Instead, JWTManager accepts an is_revoked callback:
def is_revoked(jti: str) -> bool:
return redis_client.sismember("revoked-jtis", jti)
manager = JWTManager(is_revoked=is_revoked)
Every verify_access_token / verify_refresh_token call runs the token's jti through this callback, so revoking a token (on logout, on rotation, or by an admin) just means adding its jti to your store.
Configuration reference
All configuration is read from the environment (optionally via a .env file, loaded automatically the first time JWTSettings.from_env() runs).
| Variable | Required | Default | Notes |
|---|---|---|---|
JWT_ALGORITHM |
no | RS256 |
Any PyJWT-supported algorithm: RS256/384/512, ES256/384/512, PS256/384/512, HS256/384/512 |
JWT_PRIVATE_KEY_PATH / JWT_PRIVATE_KEY |
for asymmetric algorithms, to issue tokens | - | Path to a PEM file, or the raw PEM (with \n escapes) |
JWT_PUBLIC_KEY_PATH / JWT_PUBLIC_KEY |
for asymmetric algorithms, to verify tokens | - | Path to a PEM file, or the raw PEM (with \n escapes) |
JWT_SECRET_KEY |
for symmetric algorithms | - | Shared secret |
JWT_ACCESS_TOKEN_EXPIRE_MINUTES |
no | 15 |
Keep this short - access tokens are bearer credentials |
JWT_REFRESH_TOKEN_EXPIRE_DAYS |
no | 7 |
|
JWT_ISSUER |
no | - | Stamped as iss and enforced on verify when set |
JWT_AUDIENCE |
no | - | Stamped as aud and enforced on verify when set |
See .env.example for a template.
Testing
uv run python -m unittest discover -s tests -t . -v
Tests cover both HS256 and RS256 code paths, token-type separation, tampering/wrong-key/wrong-audience rejection, expiry, refresh rotation, revocation, and configuration validation - no network or external services required.
CI
.github/workflows/ci.yml runs on every push and pull request to main: it lints with ruff, runs the full test suite across Python 3.10-3.13 via uv, and does a final build check. Update this library, push, and CI will catch regressions before they reach any service that depends on it.
Security notes
- Keep access token lifetimes short (minutes) and refresh token lifetimes as short as your product allows (days, not months).
- Prefer RS256 (or another asymmetric algorithm) over HS256 whenever more than one service needs to verify tokens.
- Use
rotate_refresh_token=Trueand pair it with theis_revokedcallback so a stolen, already-rotated refresh token can be rejected on reuse. - Never log full tokens.
TokenPayload.jtiis safe to log; the raw token string is a bearer credential. - Always serve token endpoints over HTTPS.
keys/,.env, and*.pemare gitignored - do not commit real key material or secrets.
Release files for ndaedzo-shared-lib 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ndaedzo_shared_lib-0.1.0.tar.gz | 61.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ndaedzo_shared_lib-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 71.4 kB