Overview
OAuth 2.0 was designed as a delegated authorization framework for consumer web applications. A user grants a third-party app access to their Google calendar — the threat model assumes the access token is short-lived, the damage from theft is bounded, and replay attacks are mitigated by HTTPS. That threat model is wrong for payment initiation. A stolen payment authorization token replayed once drains an account. A tampered redirect parameter redirects the authorization code to an attacker. An insufficient proof-of-possession model means a token stolen from one client can be used by any bearer.
FAPI 2.0 (Financial-grade API Security Profile 2.0) is an OpenID Foundation specification that profiles OAuth 2.1 specifically for high-value financial API use cases. It mandates the controls that fill the gaps OAuth 2.0 leaves open: Pushed Authorization Requests to prevent parameter tampering, DPoP or mTLS to bind tokens to the client that requested them, Pushed Authorization Requests for request integrity, Rich Authorization Requests for fine-grained consent, and JARM for signed authorization responses. SAMA Open Banking Phase 2 mandates FAPI 2.0 as the security baseline for all Third Party Provider (TPP) access — AIS, PIS, and Confirmation of Funds.
FAPI 2.0 does not re-invent OAuth. It is a profile that selects specific OAuth 2.1 extensions, makes optional features mandatory, and bans unsafe options (implicit grant, resource owner password credentials, plain PKCE). You still need a solid OAuth 2.1 foundation — scopes, token lifecycle, refresh token rotation, introspection — before layering the FAPI-specific controls on top. A team that does not understand OAuth 2.1 cannot implement FAPI 2.0 correctly.
FAPI 2.0 Requirements
FAPI 2.0 is defined in two parts: the Security Profile (FAPI2SP) and the Message Signing profile (FAPI2MS). For SAMA Open Banking, both apply. The normative requirements that distinguish FAPI 2.0 from a standard OAuth 2.1 deployment:
| Requirement | RFC / Spec | What it prevents | SAMA mandate |
|---|---|---|---|
| PKCE — S256 only | RFC 7636 | Authorization code interception | Mandatory |
| PAR (Pushed Authorization Requests) | RFC 9126 | Request parameter tampering in redirect | Mandatory |
| DPoP sender-constrained tokens | RFC 9449 | Bearer token theft & replay | Mandatory (or mTLS) |
| mTLS-bound access tokens | RFC 8705 | Bearer token theft & replay | Mandatory (or DPoP) |
| RAR (Rich Authorization Requests) | RFC 9396 | Overly broad consent grants | Mandatory |
| JARM (JWT Secured Authorization Response) | OIDF spec | Authorization response tampering | Mandatory |
| No implicit grant | OAuth 2.1 | Token exposure in redirect URI | Banned |
| No ROPC grant | OAuth 2.1 | Credential phishing via TPP | Banned |
PKCE S256 (SHA-256 code challenge) is mandatory — plain PKCE is banned because the plain method provides no security benefit. The state parameter is still recommended for CSRF protection at the redirect level, but it is no longer the primary authorization request integrity mechanism — that role moves to PAR.
Pushed Authorization Requests (PAR)
The fundamental problem with the OAuth 2.0 authorization endpoint is that the authorization request parameters are sent as query parameters in a browser redirect URL. The URL is visible in the browser history, referer headers, and server access logs. An attacker who can modify the redirect URL — through an open redirect vulnerability, a misconfigured redirect_uri, or a browser extension — can tamper with the scope, redirect_uri, or state parameters before the authorization server sees them.
PAR (RFC 9126) solves this by moving the authorization request out of the browser. The client POSTs all authorization parameters directly to the server’s /par endpoint, authenticated with its client credentials. The server validates the parameters and returns a request_uri (valid for 90 seconds by default). The client then redirects the user to the authorization endpoint using only client_id and the request_uri — no sensitive parameters in the URL.
request_uri is single-use and expires in 90 secondsThe request_uri returned by the PAR endpoint is designed to be used exactly once. If a user abandons the flow and the TPP retries, it must POST to /par again and obtain a fresh request_uri. A reused request_uri indicates either a replay attack or a buggy client implementation — the authorization server must reject it and the event must be logged as a security alert with the TPP identity, IP address, and timestamp. Under SAMA’s TPRM requirements, this log is evidence for TPP security incident reporting.
# Step 1: POST authorization_details to PAR endpoint
POST /par HTTP/1.1
Host: auth.openbanking.saib.sa
Content-Type: application/x-www-form-urlencoded
Authorization: Basic dHBwLWNsaWVudC1pZDpjbGllbnQtc2VjcmV0
response_type=code
&client_id=tpp-client-id
&redirect_uri=https%3A%2F%2Ftpp.example.sa%2Fcallback
&scope=openid+payments
&code_challenge=s256-challenge-value
&code_challenge_method=S256
&authorization_details=%5B%7B
"type"%3A"sama_payment_initiation"%2C
"actions"%3A%5B"initiate"%5D%2C
"locations"%3A%5B"https%3A%2F%2Fapi.openbanking.saib.sa%2Fpis"%5D%2C
"instructedAmount"%3A%7B
"amount"%3A"500.00"%2C
"currency"%3A"SAR"
%7D%2C
"creditorAccount"%3A%7B
"iban"%3A"SA44 2000 0001 2345 6789 1234"
%7D%2C
"debtorAccount"%3A%7B
"iban"%3A"SA03 8000 0000 6080 1016 7519"
%7D
%7D%5D
# Server response
HTTP/1.1 201 Created
Content-Type: application/json
{
"request_uri": "urn:ietf:params:oauth:request_uri:bwc4JQ-ESc0w8wM7M9K7w5sNe3vh",
"expires_in": 90
}
# Step 2: redirect user with ONLY client_id + request_uri
GET /authorize?client_id=tpp-client-id
&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Abwc4JQ-ESc0w8wM7M9K7w5sNe3vh
HTTP/1.1
Host: auth.openbanking.saib.sa
DPoP — Demonstrating Proof of Possession
DPoP (RFC 9449) is the primary mechanism for sender-constraining access tokens in FAPI 2.0 when client certificate infrastructure is not available for every TPP. The core idea: the client generates an ephemeral EC key pair (P-256 or P-384) at request time, signs a DPoP proof JWT that binds the request to that key, and sends the proof alongside the access token. The resource server verifies that the proof is valid for this specific HTTP method, URL, and timestamp — and that the token’s cnf claim matches the public key in the proof.
The DPoP proof JWT header carries typ: "dpop+jwt" and the client’s ephemeral public key (jwk). The payload carries: htm (HTTP method), htu (HTTP URL without query), iat (issued-at), and jti (unique nonce to prevent replay). The proof is valid only for the specific request it is attached to.
jti replay prevention requires a distributed cacheThe jti in the DPoP proof is a random nonce that the resource server must record and reject if seen again within the proof’s validity window (typically 60–300 seconds, matching the iat skew tolerance). A single in-memory set does not work behind a load-balanced API gateway — a DPoP proof submitted to instance A can be replayed against instance B, which has no record of it. Use a distributed cache (Redis Cluster or Redis Sentinel) with a TTL matching the DPoP token lifetime, and store each seen jti as the key. On a cache miss, the proof is valid; on a hit, reject with 401 invalid_dpop_proof.
// DPoP Proof JWT — Header
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "base64url-encoded-x-coordinate",
"y": "base64url-encoded-y-coordinate"
}
}
// DPoP Proof JWT — Payload
{
"jti": "e1e24b80-7d1d-4f6a-9cb0-2a5b3c1d4e5f", // unique nonce; must be cached + rejected if replayed
"htm": "GET", // must match actual HTTP method
"htu": "https://api.openbanking.saib.sa/pis/v1/payment-consents", // no query string
"iat": 1725494400, // issued-at; server rejects if skew > 60s
"ath": "base64url(sha256(access_token_bytes))" // RFC 9449 ยง4.3: bind proof to specific token
}
// Spring Security 6.3 — DPoP validation configuration (resource server side)
// In SecurityFilterChain bean:
http
.oauth2ResourceServer(rs -> rs
.jwt(jwt -> jwt
.decoder(NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build())
)
.opaqueToken(opaque -> opaque
.introspectionUri("https://auth.openbanking.saib.sa/realms/openbanking/protocol/openid-connect/token/introspect")
.introspectionClientCredentials(clientId, clientSecret)
)
)
.addFilterBefore(new DPoPProofValidationFilter(dpoPProofValidator, jtiCache), BearerTokenAuthenticationFilter.class);
// DPoPProofValidationFilter validates:
// 1. typ = "dpop+jwt"
// 2. alg in allowlist (ES256, RS256 — no none/HS256)
// 3. htm matches request method
// 4. htu matches request URL (scheme + host + path, no query)
// 5. iat within 60s clock skew window
// 6. jti not seen in Redis cache (insert + TTL on first sight)
// 7. token cnf.jkt thumbprint matches JWK in DPoP header
mTLS-Bound Tokens
mTLS sender-constraining (RFC 8705) is an alternative to DPoP — stronger in some respects, more operationally demanding in others. Instead of a per-request DPoP proof, the TPP presents its TLS client certificate during the token request. The authorization server computes a thumbprint of the certificate (x5t#S256: SHA-256 of the DER-encoded certificate) and embeds it in the access token’s cnf claim. The resource server extracts the certificate from the TLS session (via the X-Client-Cert header at Kong, injected by the TLS terminator) and verifies the thumbprint matches the token’s cnf.x5t#S256.
The security model is stronger than DPoP in that the binding is established at the TLS layer before the HTTP request reaches the application — a stolen access token is useless without the private key of the client certificate. The operational cost is higher: every TPP must have a client certificate provisioned, renewed, and revoked through a certificate management process, and the certificate infrastructure must integrate with SAMA’s TPRM TPP registration process.
| Feature | What it prevents | Client complexity | Server complexity | SAMA mandate |
|---|---|---|---|---|
| PAR | Request parameter tampering in browser redirect | Low — extra POST before redirect | Medium — PAR endpoint, request_uri storage | Mandatory |
| DPoP | Bearer token theft & replay across clients | Medium — ephemeral key pair per request, proof JWT | High — proof validation, distributed jti cache | Mandatory (or mTLS) |
| mTLS-bound | Bearer token theft (stronger than DPoP) | High — client cert provisioning & rotation | Medium — thumbprint validation at TLS terminator | Mandatory (or DPoP) |
| RAR | Overly broad consent grants; scope creep | Medium — structured authorization_details object | High — consent store, query endpoint, token introspection | Mandatory |
| JARM | Authorization response parameter tampering | Low — parse JWT response mode | Medium — JARM signing key, response_mode=jwt | Mandatory |
Rich Authorization Requests (RAR)
Standard OAuth 2.0 scopes are coarse-grained: openid payments accounts grants blanket access to all of the TPP’s payment and account operations. SAMA Open Banking Phase 2 requires granular consent that specifies the exact accounts, amounts, and operations covered by each authorization. RAR (RFC 9396) adds the authorization_details parameter to carry a structured JSON array of consent objects with rich, resource-specific semantics.
Each authorization_details object carries: type (the resource type identifier, e.g. sama_account_information or sama_payment_initiation), actions (what operations are permitted), locations (the resource server URLs that may receive the token), and resource-specific fields (amount, currency, account identifiers for PIS; account list for AIS). The authorization server embeds the consented authorization_details into the access token, and the resource server validates on every call that the requested operation is within the consented scope.
SAMA’s consent model defines three consent states: pending (created, awaiting PSU authorization), authorised (PSU approved), rejected (PSU declined or expired), and revoked (PSU or TPP explicitly cancelled). The resource server must check the consent status on every API call, not just at token issuance — a consent can be revoked mid-session.
Keycloak 24 FAPI 2.0 Configuration
Keycloak 24 ships a built-in FAPI 2.0 Security Profile realm policy that can be enabled via Admin Console or the REST API. It configures the realm to enforce PKCE S256, require PAR, enable JARM, and validate DPoP proofs. The client policy engine applies these checks to all clients in the realm that are tagged as FAPI 2.0 clients.
{
"realm": "openbanking",
"enabled": true,
"sslRequired": "all",
"accessTokenLifespan": 300,
"ssoSessionMaxLifespan": 7776000, // 90 days for AIS per SAMA
"clientPolicies": {
"policies": [
{
"name": "sama-fapi2-policy",
"enabled": true,
"conditions": [
{ "condition": "client-roles",
"configuration": { "roles": ["fapi2-client"] } }
],
"profiles": ["fapi-2-security-profile", "fapi-2-message-signing"]
}
],
"profiles": [
{
"name": "fapi-2-security-profile",
"executors": [
{ "executor": "pkce-enforcer",
"configuration": { "allow-plain-method": false } },
{ "executor": "par-enforcer",
"configuration": {
"par-required": true,
"request-uri-lifespan": 90
}
},
{ "executor": "dpop-bind-enforcer",
"configuration": {
"dpop-required": true,
"allowed-algorithms": ["ES256", "PS256"]
}
},
{ "executor": "holder-of-key-enforcer",
"configuration": { "autoConfigure": true } },
{ "executor": "jarm-enforcer",
"configuration": {
"signing-key-id": "jarm-signing-rsa-key",
"response-mode": "jwt"
}
},
{ "executor": "secure-response-type-enforcer",
"configuration": {
"allow-token-response-type": false // no implicit flow
}
}
]
}
]
},
"attributes": {
"par.request.uri.lifespan": "90",
"oauth2.device.code.lifespan": "0",
"cibaBackchannelAuthRequestedExpiry": "120"
}
}
Kong & IBM API Connect Enforcement
The authorization server (Keycloak) issues FAPI 2.0-compliant tokens. The API gateway (Kong 3.7 or IBM API Connect 10.0.7) enforces token validity, DPoP binding, consent scope, and rate plans on every API call from TPPs. The gateway sits between the internet and the resource server — no API call reaches the resource server without passing through Kong’s plugin chain.
Kong’s OIDC plugin performs token introspection against Keycloak on every request. The response includes the cnf claim (DPoP or mTLS binding), the authorization_details (RAR consent), and the sub (TPP identity). A custom Lua plugin validates that the DPoP proof in the DPoP header matches the cnf.jkt thumbprint in the introspected token. IBM API Connect accomplishes the same via a GatewayScript assembly policy that reads the introspection response and validates the cnf claim.
Rate plans in IBM API Connect are tied to the SAMA consent type: an AIS (Account Information Service) plan allows high-frequency reads (up to 4 calls per second per TPP per account), while a PIS (Payment Initiation Service) plan is throttled more aggressively (10 payment initiations per minute per TPP) and requires the payment amount to be within the consented limit from the authorization_details.
SAMA Open Banking Phase 2 Alignment
SAMA Open Banking Phase 2 defines three API classes and distinct security requirements for each. AIS (Account Information Service) allows TPPs to read account data — balances, transactions, statements — with consent lasting up to 90 days and access tokens valid for up to 5 minutes per call. PIS (Payment Initiation Service) is single-consent, single-use: the authorization covers exactly one payment, the access token is single-use and expires after it has been used to submit the payment instruction, and the consent expires as soon as the payment reaches a terminal state. CoF (Confirmation of Funds) confirms whether a specified amount is available in an account without initiating any transaction — consent is typically longer-lived for merchant use cases.
| API Class | Consent lifetime | Token lifetime | Single-use token | DPoP / mTLS |
|---|---|---|---|---|
| AIS (Account Information) | Up to 90 days | 300 s per call | No — refresh token | Mandatory |
| PIS (Payment Initiation) | Single payment | 300 s, single-use | Yes | Mandatory |
| CoF (Confirmation of Funds) | Variable per merchant | 300 s per call | No — refresh token | Mandatory |
SAMA’s TPRM (Third-Party Risk Management) requirements mandate that TPPs are registered through the SAMA Open Banking Directory before they can onboard to any ASPSP (Account Servicing Payment Service Provider). TPP registration produces a client ID, a software statement assertion (SSA) signed by the SAMA directory, and optionally a client certificate for mTLS binding. The ASPSP’s Dynamic Client Registration (DCR) endpoint accepts the SSA and creates the OAuth 2.0 client in Keycloak automatically — no manual client provisioning.
Production Checklist
- Audit your OAuth 2.0 foundation first. Ensure token introspection works, refresh token rotation is enabled, and all existing scopes map to a resource server. FAPI 2.0 controls layer on top — a weak OAuth foundation magnifies vulnerabilities rather than containing them.
- Enable PAR in Keycloak. Set
par.request.uri.lifespanto 90 seconds. Instrument the PAR endpoint to log every request with TPP identity, IP, and request_uri jti. Alert on reuse of any request_uri. - Choose DPoP or mTLS binding (or both). DPoP is lower-friction for TPP onboarding; mTLS is stronger and easier to enforce at the TLS layer. If your TPP onboarding process can provision client certificates, prefer mTLS; otherwise DPoP with a robust jti cache is SAMA-compliant.
- Deploy the jti replay prevention cache. Redis Cluster with TTL equal to the DPoP proof validity window (60–300 seconds). Instrument cache hit rate — a hit on a jti that is not from a known legitimate retry is a security event.
- Implement RAR consent model. Define
authorization_detailstypes for AIS, PIS, and CoF matching SAMA Open Banking schema. Build a consent store (persistent, queryable by TPP + PSU + status). Expose a consent query endpoint. Embed approvedauthorization_detailsin the token introspection response. - Configure JARM in Keycloak. Generate a dedicated RSA-4096 JARM signing key. Set
response_mode=jwtas the required response mode for all FAPI 2.0 clients. Verify that TPP clients correctly parse and validate the JARM JWT before extracting the authorization code. - Configure Kong DPoP validation plugin. Validate DPoP proof on every resource server request (not just at token issuance). Forward the client certificate header (
X-Client-Cert) from the TLS terminator for mTLS binding validation.
- PAR endpoint active;
request_urisingle-use enforced; reuse logged as security event. - PKCE S256 mandatory; plain challenge method rejected at authorization endpoint.
- DPoP or mTLS binding active on all TPP-facing token and resource endpoints.
- jti replay prevention cache (Redis Cluster) deployed with TTL matching DPoP validity window.
- JARM active;
response_mode=jwtenforced; JARM signing key rotated annually. - RAR
authorization_detailsschema validated at PAR endpoint; consent store persists approved details. - Consent status checked on every API call, not just at token issuance.
- AIS access token lifetime: 300 s; PIS token single-use enforced at resource server.
- Keycloak client policies applying FAPI 2.0 profile to all TPP clients; manual client creation blocked.
- Dynamic Client Registration active; SAMA directory SSA validated before client creation.
- TPP identity (client_id, SSA subject) logged in every audit event (PAR, auth, token, resource access).
- Implicit grant, ROPC, and plain PKCE blocked at realm policy level.
- Kong rate plans differentiated by consent type (AIS vs PIS); PIS rate plan aligned with SAMA API class limits.