FAPI 2.0
FAPI is the OpenID Foundation's Financial-grade API profile, a set of additional requirements on top of OAuth 2.0 and OIDC that address the threat model of regulated financial APIs. The headline differences from baseline OAuth are mandatory Pushed Authorization Requests (PAR), mandatory sender-constrained tokens through DPoP or mTLS, optional JWT Authorization Response Mode (JARM), and stricter ID token lifetime bounds.
The feature flag is fapi (off by default), which implies oauth.
The base OAuth chapter (OAuth 2.0 and OIDC) covers everything that
remains true under FAPI; this chapter covers only what changes.
Whether FAPI applies to you
What the profile is, what it demands, and who needs it.
Axess is the Relying Party, not the OP
A FAPI deployment has two parties. The OpenID Provider (OP, also called the IdP) owns user identity, runs the login UI, and issues tokens; in open-banking this is typically the bank's own SSO or a hosted Keycloak / Ory Hydra / Curity instance. The Relying Party (RP) is the application that delegates identity to the OP, accepts the resulting tokens, and runs a session on top. Axess fills the RP role. PAR, DPoP, JARM, and RP-Initiated Logout are all RP-side protocols that exist to talk to an external OP; without an OP to talk to, none of them make sense.
This is a deliberate architectural choice. Building a FAPI-conformant
OP is a multi-year project (Keycloak, Hydra, Curity, and the
commercial vendors are the established options) and is largely
disjoint from the RP-side machinery axess provides (sessions, MFA
verifiers, Cedar authorization). The verifier-vs-orchestrator split
in the workspace (covered in Architecture at a glance) is the
internal expression of the same boundary; axess does not become the
OP, and adopters are expected to point at one. The
examples/fapi/
crate ships a pre-configured Keycloak realm in a podman container as
a quick way to get an OP locally for the demo, but in production the
issuer URL would point at whatever OP your organisation already runs.
The local-idp feature is the one place axess does issuance, but
that is on-host workload-identity issuance (service-to-service flows
where a sidecar mints JWTs for its own workloads), not a
user-facing OP. Local IdP covers that surface.
What FAPI changes
Four mechanisms, each closing a specific gap in baseline OAuth.
PAR: parameters off the redirect URL
Pushed Authorization Requests (RFC 9126) POST the authorization
parameters straight to the IdP, which returns an opaque request_uri.
The authorize URL then carries only the client id and that reference.
Two gaps close at once: the parameters never reach browser history or a referer header, and the user cannot tamper with what they are only holding a reference to.
DPoP: tokens bound to a key
DPoP (RFC 9449) binds the access token to a key pair the client holds. Every request carries a JWT signed with that key, and the resource server checks the access token was issued for its thumbprint.
That defeats bearer-token theft. An attacker who lifts the token from logs, a misconfigured proxy or a debugging surface cannot use it without the private key, which never leaves the client.
JARM: signed authorization responses
The IdP returns the authorization response as a signed JWT rather than query parameters, so it cannot be tampered with after issuance. Optional in FAPI 2.0; some implementations use it, some do not.
Short ID token windows
FAPI 2.0 requires nbf to be enforced and the lifetime kept short.
Axess defaults to five minutes and refuses an ID token whose nbf is in
the future or whose exp is more than five minutes out. That closes
replay through stale tokens.
When to reach for FAPI
The honest answer is: when a regulator requires it. FAPI 2.0 was designed for the open-banking ecosystem and similar regulated financial APIs, and adopting it imposes operational complexity (every client needs DPoP key management, every authorize call goes through PAR, every IdP must support the PAR endpoint) that is substantial relative to the security benefit for non-regulated deployments. A consumer-facing SaaS that takes credit card payments through Stripe does not need FAPI; an open-banking application that acts as an account-information service provider does.
The decision is binary: either you need FAPI because someone is asking you for compliance evidence, or you do not. If you do, the mechanisms below are non-negotiable, and axess implements them. If you do not, the baseline OAuth chapter covers what you need.
The mechanisms
The four things FAPI adds over ordinary OAuth.
Configuration
FAPI is enabled per-provider by attaching a FapiConfig to an
OAuthProviderConfig:
use axess::federation::oauth::{FapiConfig, SenderConstraint, OAuthProviderConfig};
let fapi_config = FapiConfig {
sender_constraint: SenderConstraint::DPoP,
require_jarm: false,
max_id_token_lifetime_secs: 300,
};
let provider = OAuthProviderConfig::discover(
"https://idp.example.com/.well-known/openid-configuration",
client_id,
client_secret,
redirect_uri,
)
.await?
.with_fapi(fapi_config);
sender_constraint chooses between DPoP and mTLS for the
sender-constrained-tokens requirement. DPoP is the right choice for
applications that already manage HTTPS in software; mTLS is the
right choice for applications that already manage X.509 certificates
for service-to-service authentication. The two cannot be combined
on a single provider, but different providers in the same
application can use different constraints.
require_jarm toggles JARM enforcement. When true, the authorization
response from the IdP must arrive as a signed JWT; the
configuration's oidc.discovery.jwks_uri is used to verify the
signature. When false, the IdP may return the response as query
parameters as in baseline OAuth.
max_id_token_lifetime_secs is the upper bound on ID token validity.
The FAPI default is three hundred seconds (five minutes), which is
short enough that a captured token expires before most replay attacks
can succeed and long enough that clock skew does not cause spurious
rejections.
The PAR flow
With FAPI enabled, you start a federated login through the PAR-enhanced auth URL rather than the query-parameter auth URL:
let auth_url = service
.begin_oauth_login(&session, "fapi-provider", OAuthLoginOptions::default())
.await?;
// auth_url looks like:
// https://idp.example.com/authorize?client_id=...&request_uri=urn:ietf:params:oauth:request_uri:...
Internally, begin_oauth_login detects the FAPI configuration and
takes the PAR branch. The branch performs a POST to the IdP's PAR
endpoint with the full set of authorization parameters (client id,
redirect URI, scopes, PKCE challenge, CSRF state, nonce), receives
the request_uri and its expires_in, and constructs the
shorter authorize URL the user is redirected to.
The PAR exchange happens server-to-server and is authenticated. The
authentication is whatever the IdP requires (client secret POST,
client secret basic, mTLS, or signed JWT assertion); axess passes
through the credential that OAuthProviderConfig was constructed
with.
The callback flow on your side is unchanged. The IdP
redirects the user back to your callback URL with a
code; you call finish_oauth_login with the code and
state; axess performs the token exchange and ID token validation.
DPoP key management
DPoP binds each access token to a public key the client controls. The application generates a key pair at session start (or at application start, for some deployments), uses the private key to sign a DPoP proof JWT on each request to a protected resource, and the resource server verifies the proof and matches the JWK thumbprint against the access token's binding.
Axess exposes the proof-generation primitive through
OAuthProvider::generate_dpop_proof:
let proof: DpopProof = provider.generate_dpop_proof(
"GET", // HTTP method
"https://resource.example.com/data", // target URL
Some(&access_token), // bind to this access token
&dpop_key, // your key
)?;
let response = http_client
.get("https://resource.example.com/data")
.header("Authorization", format!("DPoP {}", access_token))
.header("DPoP", &proof.proof_jwt)
.send()
.await?;
The proof JWT contains the HTTP method, the target URL, a nonce, a
timestamp, and the thumbprint of the binding key. The resource
server checks all of these against the access token's cnf
(confirmation) claim, which carries the thumbprint at token
issuance.
Key lifecycle is the operational concern. A DPoP key pair generated per session is the safest choice (a compromised session is bounded to one key); a key pair generated per application instance is the easiest choice (one key to manage). The trade-off is between blast radius and operational complexity. Most deployments choose per-session keys for high-sensitivity flows and per-instance keys for routine flows.
Token revocation
FAPI 2.0 expects that compromised tokens can be revoked through the
IdP's revocation endpoint (RFC 7009). The application calls
revocation when the user logs out, when a session is administratively
ended, or when token theft is detected. Axess exposes revocation
through OAuthProvider::revoke_token:
provider.revoke_token(&access_token, Some("access_token")).await?;
provider.revoke_token(&refresh_token, Some("refresh_token")).await?;
The hint is the RFC 7009 string, passed through verbatim rather than
through an enum, because an IdP may accept hints the RFC does not list.
Passing None is legal and makes the IdP search both token types.
Providers with no revocation endpoint return OAuthError::Config
rather than silently succeeding, so a deployment cannot believe it is
revoking when it is not.
The revocation endpoint, when present in the discovery document, is called with the token to revoke and an optional type hint. The IdP responds with a 200 regardless of whether the token was actually revoked (intentionally, to defeat token-existence enumeration).
Revoking the refresh token is the more important call. The access token typically has a short lifetime (matching the FAPI ID token bound) and expires on its own; the refresh token has a longer life and an unrevoked one allows continued access through new access tokens. A logout that revokes only the access token leaves the refresh token active, which is rarely what you want.
Testing FAPI flows
There are three useful test modes, picked by what you want to exercise.
For Rust unit and integration tests, the FAPI feature pairs with the
local-idp feature. The LocalIdpFixture in axess-core::testing::local_idp
mints FAPI-grade tokens with the right nbf/exp bounds and exposes
a shared JwkSet handle that a JwtVerifier borrows for signature
verification. The fixture is an in-process value, not an HTTP service:
PAR and discovery endpoints are not part of its surface. For FAPI
flows that need a real PAR exchange, use Keycloak or another OP (see
the end-to-end walkthrough below). The pattern for unit tests is to
write against an OAuthProvider trait object, parameterise it over
fixture and live, and run both in CI. Local IdP covers the fixture
in detail.
For an end-to-end browser walkthrough, the examples/fapi/
crate ships with a pre-configured Keycloak realm under
examples/fapi/keycloak/. One podman compose up -d brings up
Keycloak with PAR required, PKCE S256 required, DPoP-bound tokens
enabled, the axess-fapi-client client registered, and a seeded
user (alice/alice) ready to log in. The example's
OAuthProviderConfig::discover(...) call points at the local
Keycloak issuer through env vars, and the same code talks to a real
production IdP when those env vars point elsewhere. Docker users can
substitute docker compose for podman compose; podman is the
documented path.
For compliance certification, the OpenID Foundation runs a free
hosted conformance suite at https://www.certification.openid.net/.
It acts as a scripted OP that drives an RP through the full FAPI 2.0
test matrix including adversarial cases (missing PAR, bad DPoP,
replay, wrong audience). Point it at the example's /auth/callback
to produce a certifiable artifact; use Keycloak for everyday
development.
Threat model
FAPI 2.0 closes the attacks baseline OAuth leaves open in regulated contexts.
Against authorization-parameter tampering: PAR moves the parameters off the URL, so they cannot be modified by an intermediary.
Against bearer-token theft: DPoP (or mTLS) binds tokens to keys the attacker does not have, so a captured token is unusable.
Against ID token replay through stale tokens: the strict lifetime bound shrinks the replay window to minutes.
The attacks FAPI does not close are the same ones baseline OAuth does not close: a compromised IdP issues compromised tokens regardless of the profile, and a compromised client device gives the attacker access to the DPoP private key alongside everything else.
Troubleshooting
If the PAR exchange fails with invalid_client, your
PAR endpoint authentication does not match what the IdP expects.
Some IdPs require mTLS authentication on PAR even when the rest of
the flow uses client secrets; check the IdP's PAR documentation.
If DPoP verification fails at the resource server, the most common cause is a clock-skew issue between the client and the resource server. The DPoP proof's timestamp is checked within a small window (a few seconds typically); larger skew triggers spurious failures. Synchronise both sides against the same NTP source.
If JARM verification fails, the signing key the IdP uses for JARM may differ from the key used for ID token signing. Some IdPs publish separate JWKS for the two; the discovery document should indicate this, but configurations occasionally miss it. Inspect the discovery document.
Further reading
OAuth 2.0 and OIDC covers the base OAuth machinery this chapter extends. Workload identity overview covers the resolver side of OAuth, where axess is the resource server rather than the client. Local IdP covers the test fixture for FAPI-grade integration testing.