API keys, tokens and codes¶
Passwords, API keys stored as digests, revoked tokens, one-time codes, the second factor (TOTP),
passkeys (WebAuthn) and step-up.
In fastapi_alcazaba.auth.
In the guide: Passwords, API keys, Revoked tokens and one-time codes, Second factor, Passkeys. In an app: the example, the second factor's.
fastapi_alcazaba.auth.FastAPIPasswords
¶
FastAPIPasswords(*, parameters: ScryptParameters | None = None, pepper: str | SecretStr | None = None, min_length: int = 15, max_attempts: int = 10, lockout: int = 900, reset_ttl: int = 3600, pwned: PwnedPasswords | None = None, cache: Cache | None = None, namespace: str = 'passwords', audit: FastAPIAudit | None = None)
Passwords, from sign-up to reset.
- Hashes: scrypt with a random salt each, in a thread (it takes a fifth of a second on
purpose); with a
pepper, the password is first an HMAC of it, so that a stolen database without the pepper gives nothing to guess against. - Guesses: after
max_attemptswrong passwords for an account, its logins are refused forlockoutseconds, the right password too; the attempts of other accounts aren't affected. Put a rate limit per client on the login route too, for one client trying many accounts. - Unknown accounts take as long as known ones:
verifyhashes against a made-up hash. - Leaked passwords: with
pwned(PwnedPasswords),checksays when a password is among those leaked, which attackers try first. - Resets:
reset_tokenmakes a random token, kept as a digest forreset_ttlseconds, thatuse_reset_tokenturns into its user once; a new one replaces the user's last.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
parameters
|
ScryptParameters | None
|
scrypt's cost. |
None
|
pepper
|
str | SecretStr | None
|
a secret of 32 bytes or more, kept out of the database (your settings). It can't be rotated without the passwords: keep it. |
None
|
min_length
|
int
|
characters a new password needs (NIST SP 800-63B: 15 when the password is the only factor, 8 with another). |
15
|
max_attempts
|
int
|
wrong passwords per account before the lockout. |
10
|
lockout
|
int
|
seconds an account's logins are refused after them. |
900
|
reset_ttl
|
int
|
seconds a reset token lasts. |
3600
|
pwned
|
PwnedPasswords | None
|
checks the passwords against those leaked; None for no check. |
None
|
cache
|
Cache | None
|
the Cache whose backend keeps the attempts and the reset tokens; FastAPICache's by default. |
None
|
namespace
|
str
|
part of the keys, after the cache's prefix (see |
'passwords'
|
audit
|
FastAPIAudit | None
|
the audit trail that records each password checked ( |
None
|
hash
async
¶
hash(password: str) -> str
The hash of password to store, in PHC's format. Raises ValueError for a password
longer than MAX_PASSWORD: check tells it first.
needs_rehash
¶
needs_rehash(stored: str) -> bool
Whether stored was made with other parameters: hash the password again at the next
login, when it's at hand.
verify
async
¶
verify(user_id: Any, password: str, stored: str | None, *, request: HTTPConnection | None = None) -> bool
Whether password is the one of stored, the hash of user_id's. False when it
isn't, when the account is locked out by wrong ones, or when the backend fails. For an
account that doesn't exist, stored=None (and its user_id, or None): it takes as long
as one that does.
check
async
¶
check(password: str, *, user_inputs: Sequence[str] = ()) -> list[str]
What's wrong with password as a new one, as NIST SP 800-63B says: "too_short"
(min_length), "too_long" (MAX_PASSWORD), "contains_user_input" (one of user_inputs,
such as the username or the e-mail address's local part, of 4 characters or more), and
"pwned" (among those leaked). No rule on its characters: length is what makes it strong.
An empty list when it's fine.
reset_token
async
¶
reset_token(user_id: Any) -> str
A token for user_id to set a new password, for the link of the e-mail it's sent by;
it replaces their last one. Raises BackendUnavailable when the backend fails.
use_reset_token
async
¶
use_reset_token(token: str, *, request: HTTPConnection | None = None) -> str | None
The user ID (as a string) whose token it is, used up; None when it's unknown,
expired, used, or replaced by a newer one. Then set the new password, and end the user's
sessions (UserSessions.end_all).
fastapi_alcazaba.auth.ScryptParameters
dataclass
¶
ScryptParameters(log_n: int = 17, r: int = 8, p: int = 1)
scrypt's cost: N = 2^log_n, r, p. OWASP's: 2^17, 8, 1 (about 128 MiB and a fifth of
a second each).
fastapi_alcazaba.auth.PwnedPasswords
¶
PwnedPasswords(*, timeout: float = 2.0, fail_open: bool = True, client: Any = None, url: str = 'https://api.pwnedpasswords.com/range/')
Whether a password is among those leaked, by Have I Been Pwned's Pwned Passwords API, without sending it: only the first five characters of its SHA-1 go out (k-anonymity), and the answer is padded, so that its size doesn't tell them either. Needs httpx2.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
timeout
|
float
|
seconds for the call. |
2.0
|
fail_open
|
bool
|
when the API doesn't answer, take the password as not leaked (the default: the check is advice, and a sign-up mustn't wait for it), or raise. |
True
|
client
|
Any
|
the httpx2 client to call it with; one of its own by default. |
None
|
url
|
str
|
the API's range endpoint. |
'https://api.pwnedpasswords.com/range/'
|
count
async
¶
count(password: str) -> int
How many times password is in the leaks; 0 when it isn't (or the API didn't answer,
with fail_open).
fastapi_alcazaba.auth.FastAPIAPIKeys
¶
FastAPIAPIKeys(lookup: Lookup[R], *, header: str = 'X-API-Key', prefix: str = 'ak', bearer: bool = False, pepper: Secrets | None = None, cache_ttl: float = 0.0, cache_size: int = 1024, scheme_name: str = 'APIKey', metrics: Metrics | None = None)
Bases: Generic[R]
FastAPI dependency that authenticates a request by its API key and returns its record.
keys = FastAPIAPIKeys(load_key, pepper=settings.api_key_pepper)
@app.get("/me")
async def me(key: Annotated[StoredKey, Depends(keys)]) -> ...
@app.delete("/orders/{id}", dependencies=[Depends(keys.require("orders:write"))])
The key comes in the header (X-API-Key by default) or, with bearer=True, as
Authorization: Bearer <key>; both appear in the OpenAPI schema, for the docs' Authorize
button. A missing, malformed, unknown, wrong or expired key gets the same 401 (with
WWW-Authenticate); a valid key without a scope that require asks for gets a 403. Keys
and secrets are never logged; put a FastAPIRateLimiter in front, since each request with a
well-formed key costs a lookup.
Revoking a key: delete its record, or set its expires_at to the past. With cache_ttl,
records are kept in each process for that many seconds, so a revocation takes up to that
long to reach every process.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lookup
|
Lookup[R]
|
|
required |
header
|
str
|
the request header holding the key. |
'X-API-Key'
|
prefix
|
str
|
the start of every key ( |
'ak'
|
bearer
|
bool
|
also accept the key as |
False
|
pepper
|
Secrets | None
|
a server secret (at least 32 bytes) mixed into every digest, kept out of the database (an environment variable, a secret manager). A list rotates it: the first one is used for new keys, all of them are accepted; old peppers must stay as long as keys made with them do. Changing it without keeping the old one invalidates every key. |
None
|
cache_ttl
|
float
|
seconds each process keeps a record it looked up; 0 looks up every request. |
0.0
|
cache_size
|
int
|
records kept at most per process (the least recently used are dropped). |
1024
|
scheme_name
|
str
|
name of the scheme in the OpenAPI schema. |
'APIKey'
|
metrics
|
Metrics | None
|
where to count the outcomes ( |
None
|
require
¶
require(*scopes: str) -> Callable[..., Awaitable[R]]
A dependency like this one that also needs every scope in scopes (a 403 without
them): Depends(keys.require("orders:write")).
generate
¶
generate(*, scopes: Iterable[str] = (), expires_at: datetime | None = None) -> NewAPIKey
A new key, to show its owner once, and its record, to store. scopes are free-form
names ("orders:read"); expires_at a datetime with its time zone.
authenticate
async
¶
authenticate(key: str | None) -> R
The record of key, if it's valid; raises an HTTPException (401) otherwise. For keys
that come some other way (a WebSocket message, a test).
forget
¶
forget(key_id: str | None = None) -> None
Drop the record of key_id (every record when None) from this process's cache: call
it after revoking a key, for this process to see it at once.
fastapi_alcazaba.auth.APIKeyRecord
dataclass
¶
APIKeyRecord(id: str, digest: str, scopes: frozenset[str] = frozenset(), expires_at: datetime | None = None)
What to store for a key: everything but the key itself.
fastapi_alcazaba.auth.TokenBlocklist
¶
TokenBlocklist(cache: Cache | None = None, *, namespace: str = 'revoked', fail_open: bool = False, leeway: int = 60)
Revoked tokens (by their jti) and subjects logged out everywhere (revoke_subject).
blocklist = TokenBlocklist()
async def current_user(claims: Annotated[dict[str, Any], Depends(decode_jwt)]) -> str:
sub, iat = claims["sub"], claims["iat"]
if await blocklist.is_revoked(claims["jti"], subject=sub, issued_at=iat):
raise HTTPException(401)
return sub
Each mark lasts while the token could still be accepted, leeway included, and then the
backend drops it. When the cache has a signing_key or encryption_key, the marks are sealed
with it and planted ones are ignored; but whoever can delete keys in the backend can still
un-revoke tokens, so keep it out of reach.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cache
|
Cache | None
|
the Cache whose backend keeps the marks; FastAPICache's by default, resolved at
each call (so the blocklist can be created before |
None
|
namespace
|
str
|
part of the keys, after the cache's prefix (see |
'revoked'
|
fail_open
|
bool
|
when the backend fails, |
False
|
leeway
|
int
|
seconds your JWT library still accepts a token after its |
60
|
revoke
async
¶
revoke(token_id: str, *, expires_at: Moment | None = None, ttl: int | None = None) -> None
Refuse the token token_id (its jti) until it would have expired anyway: give its
exp as expires_at, or the seconds it has left as ttl. Raises BackendUnavailable
when the backend fails: the token isn't revoked then.
revoke_subject
async
¶
revoke_subject(subject: str, *, ttl: int) -> None
Refuse every token of subject issued before the current second: log out everywhere,
e.g. after a password change. ttl is the longest lifetime of your tokens. iat has
whole seconds, so the tokens issued during this second stay valid, among them the one you
issue right after for the current session. Raises BackendUnavailable when the backend
fails.
is_revoked
async
¶
is_revoked(token_id: str, *, subject: str | None = None, issued_at: Moment | None = None) -> bool
Whether the token is refused: its jti was revoked, or its subject was logged out
everywhere after it was issued (give its sub and iat). When the backend fails,
not fail_open.
fastapi_alcazaba.auth.OneTimeCodes
¶
OneTimeCodes(secret: Secrets, cache: Cache | None = None, *, digits: int = 6, ttl: int = 300, max_attempts: int = 5, namespace: str = 'otp')
Codes of digits digits, sent by e-mail or SMS, that prove who someone is: each lasts
ttl seconds, works once and is lost after max_attempts wrong guesses.
codes = OneTimeCodes(secret=settings.otp_secret)
code = await codes.issue(user.email, purpose="login") # send it to the user
...
if not await codes.verify(form.email, form.code, purpose="login"):
raise HTTPException(400, "Invalid or expired code")
The backend holds an HMAC of each code with secret, never the code: a plain digest of a
6-digit code is brute-forced in milliseconds by whoever reads the backend. Issuing a code
replaces the previous one and gives max_attempts again, so put a FastAPIRateLimiter on the
route that issues them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
secret
|
Secrets
|
at least 32 bytes ( |
required |
cache
|
Cache | None
|
the Cache whose backend keeps the codes; FastAPICache's by default, resolved at each call. |
None
|
digits
|
int
|
length of the codes, 4 to 10. |
6
|
ttl
|
int
|
seconds each code lasts. |
300
|
max_attempts
|
int
|
guesses allowed for each code, right or wrong. |
5
|
namespace
|
str
|
part of the keys, after the cache's prefix (see |
'otp'
|
issue
async
¶
issue(subject: str, purpose: str = '') -> str
A new code for subject (an e-mail address, a user id...) and purpose ("login",
"reset-password"...), replacing the previous one. Raises BackendUnavailable when the
backend fails.
verify
async
¶
verify(subject: str, code: str, purpose: str = '') -> bool
Whether code is the current code of subject for purpose; it's used up then. Of
several requests with the right code at the same time, only one gets True. False when the
backend fails.
fastapi_alcazaba.auth.FastAPITOTP
¶
FastAPITOTP(*, issuer: str, secret: Secrets, cache: Cache | None = None, digits: int = 6, period: int = 30, window: int = 1, algorithm: Algorithm = 'SHA1', max_attempts: int = 5, lockout: int = 900, namespace: str = 'totp', audit: FastAPIAudit | None = None)
The codes of authenticator apps as a second factor (RFC 6238), recovery codes, and step-up for the routes that need a recent one.
- The secrets are sealed with AES-256-GCM, each for its user: store
sealed, never the secret. A sealed secret copied to another user doesn't open, nor withoutsecret. - A code is accepted within
windowsteps ofperiodseconds before or after now, for clocks out of step; each one once (RFC 6238, 5.2): another request with the same code, from someone who looked over the user's shoulder, is refused. - Guesses: after
max_attemptswrong codes (recovery codes included), the user's codes are refused forlockoutseconds, whatever they are: a 6-digit code isn't guessed. - When the backend fails, nothing is accepted: no code is checked for replay, nor guesses counted.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
issuer
|
str
|
the name the authenticator app shows for the account ("Example"). |
required |
secret
|
Secrets
|
at least 32 bytes ( |
required |
cache
|
Cache | None
|
the Cache whose backend keeps the codes used and the guesses; FastAPICache's by default, resolved at each call. |
None
|
digits
|
int
|
of each code, 6 or 8. |
6
|
period
|
int
|
seconds each code lasts. |
30
|
window
|
int
|
steps accepted before and after the current one. |
1
|
algorithm
|
Algorithm
|
SHA1, which every app supports, SHA256 or SHA512. |
'SHA1'
|
max_attempts
|
int
|
wrong codes before the lockout. |
5
|
lockout
|
int
|
seconds the codes are refused after |
900
|
namespace
|
str
|
part of the keys, after the cache's prefix (see |
'totp'
|
audit
|
FastAPIAudit | None
|
the audit trail that records each second factor checked ( |
None
|
enroll
¶
enroll(user_id: str | int | UUID, label: str) -> TOTPEnrollment
A new secret for user_id's app; label is what the app shows under the issuer (their
e-mail address, their username). Store sealed once a code of it has been verified.
code
¶
code(user_id: str | int | UUID, sealed: str, at: float | None = None) -> str
The code the app shows at at (now by default): for tests, not for checking codes.
verify
async
¶
verify(user_id: str | int | UUID, sealed: str, code: str, *, request: HTTPConnection | None = None) -> bool
Whether code is one the app of user_id, whose secret is sealed, shows now (or
window steps away), and hasn't been used. False when the user is locked out by wrong
codes, or the backend fails. Raises ValueError when sealed isn't user_id's.
recovery_codes
¶
recovery_codes(user_id: str | int | UUID, count: int = 10) -> RecoveryCodes
count new recovery codes for user_id, which replace the old ones: show codes
once, store digests (instead of the old ones).
use_recovery_code
async
¶
use_recovery_code(user_id: str | int | UUID, code: str, digests: Collection[str], *, request: HTTPConnection | None = None) -> str | None
The digest of digests that code is, used up: delete it from those stored. None
when it's none of them, was used already, the user is locked out, or the backend fails.
Counts as a guess like a wrong TOTP code.
mark
¶
mark(request: HTTPConnection, user_id: str | int | UUID) -> None
Note in the session that user_id just gave their code (stepup.mark), and give
the session a new id: it holds more privileges now.
verified_at
¶
verified_at(request: HTTPConnection, user_id: str | int | UUID) -> float | None
When user_id last gave a second factor in this session; None when they didn't, or
someone else did (stepup.verified_at).
require_recent
¶
require_recent(user_id: Callable[..., Any], max_age: int = 300) -> Callable[..., Any]
A dependency that needs a second factor (this one, or a passkey) of the last
max_age seconds in this session: stepup.require_recent.
fastapi_alcazaba.auth.TOTPEnrollment
dataclass
¶
TOTPEnrollment(uri: str, secret: SecretStr, sealed: str)
A new secret for a user's authenticator app.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
uri
|
str
|
the |
required |
secret
|
SecretStr
|
the secret in base32, for those who type it in the app. |
required |
sealed
|
str
|
the secret encrypted for its user, to store with them once they've typed a
code that |
required |
fastapi_alcazaba.auth.RecoveryCodes
dataclass
¶
RecoveryCodes(codes: list[str], digests: list[str])
Codes that stand in for the app once each, for a lost phone.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
codes
|
list[str]
|
to show the user once, to print or keep in a password manager. |
required |
digests
|
list[str]
|
to store with the user; |
required |
fastapi_alcazaba.auth.FastAPIWebAuthn
¶
FastAPIWebAuthn(*, rp_id: str, rp_name: str, origins: Collection[str], user_verification: UserVerification = 'required', algorithms: Sequence[int] = (ES256, EDDSA, RS256), timeout: int = 300, cache: Cache | None = None, namespace: str = 'webauthn', audit: FastAPIAudit | None = None)
Passkeys and security keys: registering them (registration_options,
verify_registration) and logging in with them (authentication_options,
verify_authentication), as WebAuthn Level 2 says.
- The challenges are random (32 bytes), kept in the cache's backend for
timeoutseconds, bound to the ceremony and to the user they were made for, and used once: of two answers to the same challenge, one is checked. - The origin of the answer must be one of
origins, never in a cross-origin iframe, and the authenticator's data must be forrp_id: a phishing site's origin is refused, and so is its RP ID, which the browser wouldn't sign for another site. - The user must be present (a touch), and verified (a PIN, a fingerprint, the phone's
lock) with
user_verification="required", the default: a passkey is then both factors. - The counter: a credential whose counter goes back (or doesn't move, for those that count) was cloned: refused. Synced passkeys count 0, and aren't checked.
- When the backend fails, no answer is checked: the challenges can't be used once.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rp_id
|
str
|
the relying party's ID: the site's domain, or one it's under
( |
required |
rp_name
|
str
|
the name the authenticator shows ("Example"). |
required |
origins
|
Collection[str]
|
the origins the answers come from, |
required |
user_verification
|
UserVerification
|
required, preferred or discouraged. |
'required'
|
algorithms
|
Sequence[int]
|
the COSE algorithms accepted, in order of preference. |
(ES256, EDDSA, RS256)
|
timeout
|
int
|
seconds a challenge lasts, and the browser waits for the user. |
300
|
cache
|
Cache | None
|
the Cache whose backend keeps the challenges; FastAPICache's by default. |
None
|
namespace
|
str
|
part of the keys, after the cache's prefix (see |
'webauthn'
|
audit
|
FastAPIAudit | None
|
the audit trail that records each passkey registered
( |
None
|
registration_options
async
¶
registration_options(user_id: UserId, user_name: str, *, display_name: str | None = None, user_handle: str | None = None, exclude: Sequence[Passkey] = (), resident_key: Literal['required', 'preferred', 'discouraged'] = 'required') -> dict[str, Any]
The options of navigator.credentials.create(), as JSON, for user_id to register
a passkey. user_name and display_name are what the authenticator shows;
user_handle is the user's handle (base64url) when they have passkeys already, else a
new random one. exclude are their passkeys, which the authenticator won't register
twice. resident_key="required" makes a passkey, which logs in without a username.
verify_registration
async
¶
verify_registration(user_id: UserId, credential: Mapping[str, Any], *, request: HTTPConnection | None = None) -> Passkey
The Passkey of credential (the browser's credential.toJSON() of create()), to
store with user_id; WebAuthnRefused when it isn't a valid answer to a challenge made
for them. Check that no other user has its id before storing it.
authentication_options
async
¶
authentication_options(user_id: UserId | None = None, *, allow: Sequence[Passkey] = ()) -> dict[str, Any]
The options of navigator.credentials.get(), as JSON. Without a user, any passkey of
this site may answer (a login without a username, or the browser's autofill); with
user_id and allow (their passkeys), only theirs, for a second factor.
verify_authentication
async
¶
verify_authentication(credential: Mapping[str, Any], passkey: Passkey, *, user_id: UserId | None = None, request: HTTPConnection | None = None) -> Passkey
The Passkey with its new counter and last_used, to store again, when credential
(the browser's credential.toJSON() of get()) is a valid answer by passkey, the one
whose id is credential_id(credential); WebAuthnRefused otherwise. user_id is the
user passkey is stored with: the challenge, when made for a user, must be theirs.
mark
¶
mark(request: HTTPConnection, user_id: UserId) -> None
Note in the session that user_id just logged in with a passkey (stepup.mark),
for the routes of require_recent, and give the session a new id.
require_recent
¶
require_recent(user_id: Callable[..., Any], max_age: int = 300) -> Callable[..., Any]
A dependency that needs a second factor (a passkey, or a TOTP code) of the last
max_age seconds in this session: stepup.require_recent.
fastapi_alcazaba.auth.Passkey
dataclass
¶
Passkey(id: str, public_key: str, algorithm: int, user_handle: str, sign_count: int = 0, transports: tuple[str, ...] = (), backup_eligible: bool = False, backed_up: bool = False, created: float = time.time(), last_used: float | None = None)
A registered credential, to store with its user (as to_dict(), JSON) and to give back to
verify_authentication. It holds a public key: nothing in it opens a door by itself.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
id
|
str
|
the credential's ID (base64url), by which the browser names it. |
required |
public_key
|
str
|
its COSE public key (base64url). |
required |
algorithm
|
int
|
its COSE algorithm: -7 (ES256), -8 (EdDSA) or -257 (RS256). |
required |
user_handle
|
str
|
the user's handle (base64url) the authenticator keeps with it. |
required |
sign_count
|
int
|
the signature counter it last showed (0 for those that don't count). |
0
|
transports
|
tuple[str, ...]
|
how the browser reaches it ("internal", "hybrid", "usb"...). |
()
|
backup_eligible
|
bool
|
it can be synced (a passkey in iCloud Keychain, Google Password Manager). |
False
|
backed_up
|
bool
|
it is synced now. |
False
|
created
|
float
|
when it was registered (Unix time). |
time()
|
last_used
|
float | None
|
when it last logged someone in (Unix time); None until it does. |
None
|
fastapi_alcazaba.auth.WebAuthnRefused
¶
WebAuthnRefused(reason: str, detail: str = '')
Bases: Exception
A registration or a login that isn't valid: the user's answer is refused, a 400 or a 401
for your route. reason says why, for the logs (not for the user).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
reason
|
str
|
short and stable: "challenge", "origin", "rp_id", "user_presence", "user_verification", "signature", "counter", "algorithm", "malformed"... |
required |
fastapi_alcazaba.auth.credential_id
¶
credential_id(credential: Mapping[str, Any]) -> str
The ID of the credential the browser answered with (base64url): what to look its Passkey
up by, before verify_authentication.
fastapi_alcazaba.auth.stepup.mark
¶
mark(request: HTTPConnection, user_id: UserId, method: str) -> None
Note in the session that user_id just gave a second factor (method: "totp",
"passkey"...), and give the session a new id (with ServerSessionMiddleware): it holds more
privileges now.
fastapi_alcazaba.auth.stepup.verified_at
¶
verified_at(request: HTTPConnection, user_id: UserId) -> float | None
When user_id last gave a second factor in this session (Unix time); None when they
didn't, or someone else did.
fastapi_alcazaba.auth.stepup.require_recent
¶
require_recent(user_id: Callable[..., Any], max_age: int = 300) -> Callable[..., Any]
A dependency that lets through the users who gave a second factor in the last max_age
seconds in this session, and returns their ID; the others get a 403 ("Second factor
required"), to ask for one and mark the session, and requests without a user a 401.
user_id is a dependency that returns the request's user ID, or None.