Security guide¶
OAuth 2.0 and OpenID Connect are security protocols whose value depends on a
handful of checks being exactly right. pygrindvakt is a thin binding: the
protocol logic, the JOSE cryptography and the validation rules live upstream in
grindvakt, jose-rs and kryptering. This guide describes the
security properties the binding preserves, the guards it adds on top of
grindvakt, the escape hatches it deliberately leaves reachable, and the rules
an integrator must follow that no library can enforce.
Read this before wiring pygrindvakt into an authentication flow.
Fail-closed hardening added by the binding¶
The binding bakes fail-closed policy into the Python boundary (ADR 0003), so framework integrations cannot accidentally omit deployment-critical choices.
Guard |
Security property |
Escape hatch |
|---|---|---|
|
Binds the ID token to the authorization request and prevents replay across login sessions. |
|
|
Keeps the last entry, so a copy-paste error silently changes which secret or redirect URI is live. |
Use |
|
Ignores unknown fields, so |
None; fix the field name. |
|
Silently drops |
None; use |
|
grindvakt ships no HTTP client at all; a naive one would re-send
token-endpoint bodies ( |
Inject your own |
DPoP validation requires an atomic replay store; the deprecated
|
A valid server nonce is replayed along with a captured proof and is
therefore not a substitute for unique |
None; use an in-memory store for one process or a shared store across workers. |
|
Same requirement, but easy to miss. |
None; see below. |
Python protocol adapters fail closed: an exception in a store or HTTP
client is logged via |
Not applicable (Rust traits). |
None. |
If grindvakt later adopts these guards upstream, the binding’s checks become redundant but harmless.
token_url comes from configuration, never from Host¶
pygrindvakt.provider.Provider.handle_token_request(),
authenticate_client(),
pygrindvakt.dpop.validate_proof() and
pygrindvakt.dpop.validate_resource_proof() all take the absolute URL of
the endpoint being served. That URL is compared against:
the
audof aprivate_key_jwtclient assertion, andthe
htuof a DPoP proof.
If you build it from the incoming request (request.host,
request.url, build_absolute_uri()), an attacker who can influence the
Host header, or who sits behind a misconfigured reverse proxy, chooses
what those checks compare against: an assertion or proof minted for
https://attacker.example/token would validate on your OP. Derive the URL
from the same setting that produced
pygrindvakt.metadata.ProviderMetadata:
ISSUER = os.environ["OP_ISSUER"] # configuration
TOKEN_URL = f"{ISSUER}/token" # what clients are told in discovery
op.handle_token_request(form_pairs, TOKEN_URL, auth_header=auth) # correct
op.handle_token_request(form_pairs, request.url, auth_header=auth) # WRONG
The examples under examples/ all follow this rule; the Flask, Django and
FastAPI guides call it out at the token endpoint.
Never send str(exc) to a client¶
The message of a pygrindvakt.GrindvaktError is an operator-facing
diagnostic. It may quote an upstream error body, name an internal host, say
which key or algorithm was rejected, or describe why a signature failed. None
of that belongs in an HTTP response.
Exactly two things are client-safe:
pygrindvakt.OAuthError.to_response()for direct (JSON) errors at the token and userinfo endpoints, and for authorization-endpoint errors raised before theredirect_uriwas validated.pygrindvakt.OAuthError.to_redirect()for authorization-endpoint errors after validation succeeded.
Everything else should become a generic server_error:
try:
tr = op.handle_token_request(form_pairs, TOKEN_URL, auth_header=auth)
except OAuthError as e:
return to_flask(e.to_response())
except GrindvaktError:
log.exception("token endpoint failure") # str(e) goes to the log
return to_flask(OAuthError("server_error").to_response())
On the RP side the same applies to pygrindvakt.AuthnError raised by
exchange_code() or
verify_id_token(): log it, show the user a generic
“login failed” page.
The redirect policy¶
Two different redirect rules matter.
Inbound (authorization endpoint). The redirect_uri in an authorization
request is attacker-controlled until
validate_authorization_request() has
confirmed it exactly matches one registered for the client. Before that
point, render errors with to_response(); after it, to_redirect() is
safe. The split is deliberate: an OP that redirects unvalidated URIs is an
open redirector and a phishing tool.
try:
req = AuthorizationRequest.from_params(query_pairs)
op.validate_authorization_request(req)
except OAuthError as e:
return e.to_response() # NOT to_redirect: redirect_uri is untrusted
session["authz"] = req.to_dict()
# ... later, after login ...
except OAuthError as e:
mode = "fragment" if req.use_fragment() else "query"
return e.to_redirect(req.redirect_uri, mode) # safe: validated above
Matching is exact, with no prefix or wildcard support, and there is no
localhost exception.
Outbound (HTTP client). The built-in client refuses to follow redirects,
because a 307 / 308 from a token endpoint would re-send the form body
(client_secret, the authorization code, a client_assertion) to
wherever the redirect points. The RP functions treat a 3xx as a failed
request. If you inject your own HttpClient, keep that property:
follow_redirects=False in httpx, allow_redirects=False in
requests.
TLS: rustls, not the system OpenSSL¶
Outbound HTTPS uses rustls compiled into the extension, with the bundled
Mozilla root store (webpki-roots). There is no OpenSSL dependency, no
environment variable that disables verification, and no verify=False.
Two consequences:
A private CA is not picked up from the system trust store,
SSL_CERT_FILEorREQUESTS_CA_BUNDLE. To talk to an OP with a private certificate, inject anHttpClientbuilt on a Python library configured with that CA (see Relying Party integration), keeping the no-redirect and size-cap rules.pygrindvakt.rp.discover()requires anhttpsissuer. Plainhttpis accepted only for loopback hosts, which is what the examples use on127.0.0.1.
Secrets are Python strings¶
The token codec secret, client secrets, the DPoP nonce secret and a PKCS#11
PIN are passed as str. A Python string is immutable and cannot be
zeroized: it stays in memory until the garbage collector reclaims it, and it
may be copied by string operations along the way. The Rust side copies the
value into its own buffers and never exposes it back (no getter, nothing in
repr()), but the original Python object is beyond the binding’s control.
Practical rules:
Read secrets from a secret store or the environment at startup, pass them straight into the constructor, and do not keep other references.
Prefer
pygrindvakt.keys.signing_key_from_pkcs11()for the OP signing key in production: the private key never leaves the token, so only the PIN is exposed to this problem.Rotate the codec secret with
previous_secretsrather than restarting with a new one; seepygrindvakt.tokens.TokenCodec.
Input hardening the binding inherits from grindvakt¶
JWT verification rejects
alg: noneand symmetric algorithms against public keys, requiresexpwhere the protocol does, and enforcestypwhere a type is defined (entity statements, resolve responses, signed JWK sets, DPoP proofs), so a token issued for one purpose cannot be presented for another.Discovery requires the
issuerin the fetched document to equal the requested one exactly (OIDC Discovery section 4.3), which stops a compromised or mis-served document from redirecting the RP to another provider’s endpoints.PKCE defaults to
S256and the OP advertises onlyS256.Authorization codes, refresh tokens and assertion
jtivalues are single-use, enforced through the token-use store. Refresh tokens rotate.Federation verifies the whole trust chain: the trust anchor’s entity configuration against the keys you configured, the resolve response’s
typ, the chain’s start (the subject) and end (the anchor), every subordinate statement’s signature, and thesubyou asked for.Discovery-service URLs (
initiate_login_uri) must behttpswithout a fragment before a user is ever sent to them.
Footguns the API leaves reachable (and why)¶
Each is named so it stands out in a code review.
unsafe_skip_nonce_check=TrueDisables the nonce check in
pygrindvakt.rp.verify_id_token()and emits aUserWarning. Exists for pure OAuth 2.0 flows that return an id_token without ever having sent a nonce. In the standard code flow it allows id_token replay. See Relying Party integration.pygrindvakt.jwt.peek_claims_unverified()andpygrindvakt.federation.decode_unverified()Return claims without checking any signature. For inspection only (which key set to fetch, which authority to ask). Never make a decision on their output.
- Python protocol adapters
A
ClientStore,TokenUseStore,ReplayStoreorHttpClientyou write yourself is inside the trust boundary. The binding guarantees that an exception fails closed; it cannot guarantee that aconsumethat always returnsTrueis a bug. Test them.- In-memory stores
InMemoryTokenUseStore,InMemoryReplayStoreandInMemoryClientStoreare per process. Behind a multi-worker server, a code consumed by one worker is fresh in every other one. See Stores, workers and the runtime.
Checklist for a production OP¶
Derive the issuer and every endpoint URL, in particular
token_url, from configuration; never fromHost.Use a shared, fail-closed token-use store (
RedisStore, constructed after fork, or a Python-backed one) whenever more than one process serves the token endpoint.Render authorization-endpoint errors with
to_response()untilvalidate_authorization_requesthas succeeded, then withto_redirect().Send only
OAuthError.to_response()/to_redirect()output to clients; log everything else.Keep the signing key on a PKCS#11 token where you can; otherwise load it from a file with restrictive permissions, never from source.
If you enable DPoP, use an atomic shared replay store across workers. A nonce is defense in depth, not a replacement for replay tracking.
Do not set any
unsafe_*argument.
Checklist for a production RP¶
Generate fresh
state,nonceand PKCE verifier per login, store them in the user’s session, and checkstateon the callback before doing anything else.Always pass the stored nonce to
verify_id_token; neverunsafe_skip_nonce_check.Pass a non-empty allowlist of ID-token signing algorithms derived from trusted provider configuration, and opt in explicitly to any additional audience you trust.
Prefer
private_key_jwtover shared secrets; the OP then holds only your public key.Keep the built-in HTTP client, or make sure your own never follows redirects, enforces timeouts and caps bodies.
Treat
AuthnErrormessages as log material.