auth-paseto
Access/refresh token authentication built on PASETO (Platform-Agnostic Security Tokens), a safer alternative to JWT. Framework-agnostic, with first-class support for async frameworks such as FastAPI.
Features
- PASETO v1–v4 (defaults to v4), powered by pyseto.
- Both purposes:
local(symmetric encryption) andpublic(asymmetric signatures). - Access and refresh tokens with independent lifetimes and type checking on verification.
- Fresh tokens for sensitive operations (e.g. password changes).
- Revocation through an async callback (e.g. a denylist in Redis/Valkey).
- Typed: Pydantic configuration and a
py.typedmarker.
Installation
pip install auth-paseto
# or
uv add auth-paseto
Requires Python 3.12+.
Quick start
import asyncio
import secrets
from auth_paseto import AuthConfig, PASETOAuth
auth = PASETOAuth(AuthConfig(secret_key=secrets.token_urlsafe(32)))
token = auth.create_access_token(subject="user_123", user_claims={"role": "admin"})
payload = asyncio.run(auth.get_access_token_payload(token))
print(payload["sub"], payload["role"]) # user_123 admin
Configuration
Local (symmetric) tokens
from auth_paseto import AuthConfig, TokenPurpose
config = AuthConfig(
secret_key="<at least 32 random bytes>", # e.g. secrets.token_urlsafe(32)
purpose=TokenPurpose.LOCAL, # default
)
Use a long, randomly generated secret loaded from your environment or secret manager; never commit it.
Public (asymmetric) tokens
Tokens are signed with the private key and verified with the public key. Keys are PEM-encoded
and can be given inline (private_key, public_key) or as file paths:
from auth_paseto import AuthConfig, TokenPurpose
config = AuthConfig(
purpose=TokenPurpose.PUBLIC,
private_key_file="keys/private.pem",
public_key_file="keys/public.pem",
)
For v4 (the default), generate an Ed25519 key pair:
openssl genpkey -algorithm ed25519 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem
Options
| Option | Type | Default | Description |
|---|---|---|---|
secret_key |
str |
None |
Symmetric key. Required for local. |
private_key / private_key_file |
str |
None |
PEM private key (or path to it). Required for public. |
public_key / public_key_file |
str |
None |
PEM public key (or path to it). Required for public. |
purpose |
TokenPurpose |
LOCAL |
local or public. |
version |
PASETOVersion |
v4 |
PASETO protocol version (1–4). |
access_token_expires |
int | timedelta | False |
30 min |
Access token lifetime (seconds if int). False disables expiry. |
refresh_token_expires |
int | timedelta |
15 days |
Refresh token lifetime (seconds if int). |
decode_leeway |
int | timedelta |
2 |
Clock-skew tolerance when verifying, in seconds. |
encode_issuer |
str |
None |
Sets the iss claim on issued tokens. |
encode_audience |
str |
"" |
Sets the aud claim on issued tokens (omitted if empty). |
decode_issuer |
str |
None |
If set, verification rejects tokens whose iss claim differs. |
decode_audience |
str |
None |
If set, verification rejects tokens whose aud claim differs. |
Usage
Creating tokens
access = auth.create_access_token(subject="user_123", user_claims={"role": "admin"})
fresh = auth.create_access_token(subject="user_123", fresh=True)
refresh = auth.create_refresh_token(subject="user_123")
Every token carries sub, jti (a UUID4), type, iat, nbf and exp claims; access tokens
also carry fresh. Pass base64_encode=True to get the token base64-encoded.
Verifying tokens
The verification methods are coroutines that return the decoded payload as a dict:
payload = await auth.get_access_token_payload(token) # access tokens only
payload = await auth.get_fresh_access_token_payload(token) # fresh access tokens only
payload = await auth.get_refresh_token_payload(token) # refresh tokens only
On failure they raise TokenVerificationError. Its detail attribute is a Pydantic model with
a machine-readable type (ErrorCode) and a human-readable reason:
ErrorCode |
Raised when |
|---|---|
TOKEN_MISSING |
The token is empty or None. |
TOKEN_INVALID_ERROR |
The token is malformed, tampered with, expired, not yet valid, or has the wrong issuer/audience. |
UNEXPECTED_TOKEN_TYPE |
E.g. a refresh token was passed where an access token was expected. |
EXPECTED_FRESH_TOKEN |
A fresh token was required but the token is not fresh. |
TOKEN_REVOKED |
The revocation callback reported the token as revoked. |
from auth_paseto import ErrorCode, TokenVerificationError
try:
payload = await auth.get_access_token_payload(token)
except TokenVerificationError as e:
if e.detail.type == ErrorCode.TOKEN_INVALID_ERROR:
...
Revocation
Pass an async callback that receives the token's jti and returns True if the token is
revoked. It runs on every verification.
async def is_revoked(jti: str) -> bool:
return await redis.exists(f"revoked:{jti}") > 0
auth = PASETOAuth(config, is_revoked_callback=is_revoked)
To revoke a token (e.g. on logout), store its jti until the token would have expired:
payload = await auth.get_refresh_token_payload(token)
await redis.set(f"revoked:{payload['jti']}", 1, ex=auth.refresh_token_expires)
FastAPI integration
auth-paseto has no dependency on FastAPI. Wire it in with a token extractor, a dependency and an exception handler:
from typing import Annotated, Any
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordBearer
from auth_paseto import AuthConfig, PASETOAuth, TokenVerificationError
auth = PASETOAuth(AuthConfig(secret_key=os.environ["AUTH_SECRET_KEY"]))
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login")
app = FastAPI()
@app.exception_handler(TokenVerificationError)
async def token_error_handler(request: Request, exc: TokenVerificationError):
return JSONResponse(
status_code=401,
content=exc.detail.model_dump(mode="json"),
headers={"WWW-Authenticate": "Bearer"},
)
async def access_payload(token: Annotated[str, Depends(oauth2_scheme)]) -> dict[str, Any]:
return await auth.get_access_token_payload(token)
async def fresh_payload(token: Annotated[str, Depends(oauth2_scheme)]) -> dict[str, Any]:
return await auth.get_fresh_access_token_payload(token)
@app.post("/login")
async def login():
# ... authenticate the user first ...
user_id = "user_123"
return {
"access_token": auth.create_access_token(subject=user_id, fresh=True),
"refresh_token": auth.create_refresh_token(subject=user_id),
"token_type": "bearer",
}
@app.get("/me")
async def me(payload: Annotated[dict[str, Any], Depends(access_payload)]):
return {"user_id": payload["sub"]}
@app.post("/change-password")
async def change_password(payload: Annotated[dict[str, Any], Depends(fresh_payload)]):
return {"message": "Access granted to sensitive operation"}
Development
git clone https://gitlab.com/Soralsei/auth_paseto.git
cd auth_paseto
uv sync
uv run pytest
License
Metadata
Release files for auth-paseto 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 | |
|---|---|---|---|
| auth_paseto-0.1.0.tar.gz | 7.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| auth_paseto-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 16.7 kB
Release files / auth_paseto-0.1.0.tar.gz
| Download URL | auth_paseto-0.1.0.tar.gz |
|---|---|
| Size | 7.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f6ea38c13c6341d693699b0087a2748e121e3c5c260f0899fe9ee63c6be7f804
|
|
BLAKE2b-256 checksum How to use checksums |
bc2429edce40d3435a314d5c2b5c85e39085c7b6584e49a6ba1908643072b402
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / auth_paseto-0.1.0-py3-none-any.whl
| Download URL | auth_paseto-0.1.0-py3-none-any.whl |
|---|---|
| Size | 9.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
35483913a55d7e665201f5cd266f3a86defc60af54901ebc5b6fff02ab2e8f4e
|
|
BLAKE2b-256 checksum How to use checksums |
a33d922dcc311cefe24a1e9f0c9c5b5f62678da8811920205042ada29a9dfb74
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|