Step-Up Authentication¶
The step-up authentication module (blockauth.stepup) implements RFC 9470-style receipt-based step-up auth. After a user completes an additional factor (e.g., TOTP), a short-lived signed receipt is issued. Other services validate this receipt before allowing sensitive operations.
This module is Django-independent -- it uses only PyJWT and the Python standard library.
Concepts¶
- Receipt: A short-lived HS256 JWT proving the user completed a step-up challenge
- Issuer: The service that verifies the additional factor and creates the receipt
- Validator: The service that checks the receipt before allowing a sensitive operation
- Scope: Restricts the receipt to a class of operations (e.g.,
mpc,withdrawal) - Audience: Prevents cross-service replay (
audclaim)
Issuing a Receipt¶
After the user passes TOTP (or any step-up factor):
from blockauth.stepup import ReceiptIssuer
issuer = ReceiptIssuer(
secret=RECEIPT_SHARED_SECRET, # min 32 characters
issuer="my-auth-service",
default_audience="my-wallet-service",
default_scope="mpc",
default_ttl_seconds=120, # 2 minutes
)
receipt_token = issuer.issue(subject=str(user.id))
# Return receipt_token in the API response
Validating a Receipt¶
In the consuming service:
from blockauth.stepup import ReceiptValidator, ReceiptValidationError
validator = ReceiptValidator(
secret=RECEIPT_SHARED_SECRET,
expected_audience="my-wallet-service",
expected_scope="mpc",
)
try:
claims = validator.validate(
token=receipt_from_header,
expected_subject=authenticated_user_id, # anti-IDOR check
)
# claims.subject, claims.scope, claims.jti, etc.
except ReceiptValidationError as e:
# e.reason -- human-readable message
# e.code -- machine-readable code
return 403, {"error": e.reason}
Receipt JWT Claims¶
{
"sub": "user-uuid",
"type": "stepup_receipt",
"aud": "my-wallet-service",
"scope": "mpc",
"iat": 1740000000,
"exp": 1740000120,
"jti": "random-hex-16-bytes",
"iss": "my-auth-service"
}
Validation Checks¶
| Check | Error Code |
|---|---|
| HS256 signature valid | receipt_signature_invalid |
Not expired (exp > now) |
receipt_expired |
type == "stepup_receipt" |
receipt_wrong_type |
aud matches expected |
receipt_audience_mismatch |
scope matches expected |
receipt_scope_mismatch |
sub matches authenticated user |
receipt_subject_mismatch |
Middleware Pattern¶
The receipt is typically passed as an HTTP header (X-TOTP-Receipt). Apply middleware to protected endpoints:
- Header present: must be valid or request is rejected (403)
- Header absent: pass through (for users who didn't do TOTP)
- Enforce mode: reject all requests without a valid receipt (opt-in for strict environments)
API Reference¶
ReceiptIssuer(secret, *, issuer, default_audience, default_scope, default_ttl_seconds)¶
Create an issuer. secret must be >= 32 characters.
issue(subject, *, audience=None, scope=None, ttl_seconds=None)returnsstr(JWT)
ReceiptValidator(secret, *, expected_audience, expected_scope)¶
Create a validator.
validate(token, *, expected_subject=None)returnsReceiptClaims
ReceiptClaims (frozen dataclass)¶
Fields: subject, audience, scope, issued_at, expires_at, jti, issuer
ReceiptValidationError¶
Fields: reason (str, human-readable), code (str, machine-readable)