Authentication Protocols in Open Banking
Authentication Protocols in Open Banking
Open banking mandates that banks expose customer account data and payment initiation through APIs — to authorized third parties, with the customer’s explicit consent. This is a fundamental shift: for the first time, banks don’t control all access to their customers’ financial data.
The regulatory frameworks (PSD2 in Europe, Open Banking Standard in the UK, Consumer Data Right in Australia) specify what must be shared but leave significant latitude on how. The result is a complex interplay of OAuth 2.0, mutual TLS, consent management, and Strong Customer Authentication (SCA) — each layer addressing a different threat vector.
PSD2 and Strong Customer Authentication
PSD2’s Article 97 mandates Strong Customer Authentication (SCA) for electronic payment transactions. SCA requires two of three independent authentication factors:
- Knowledge: something the customer knows (PIN, password)
- Possession: something the customer has (phone, card, hardware token)
- Inherence: something the customer is (fingerprint, face recognition)
from dataclasses import dataclass, field
from enum import Enum, Flag, auto
from datetime import datetime, timedelta
class AuthFactor(Flag):
KNOWLEDGE = auto() # PIN, password, security question
POSSESSION = auto() # Phone, card, hardware token
INHERENCE = auto() # Fingerprint, face, voice
@dataclass
class SCAResult:
factors_used: AuthFactor
timestamp: datetime
transaction_amount: float | None
transaction_currency: str | None
payee: str | None
@property
def is_sca_compliant(self) -> bool:
"""
SCA requires at least two independent factors.
The independence requirement means the compromise of one
factor must not compromise the other. A password + SMS OTP
qualifies. A password + password hint does not.
"""
factor_count = bin(self.factors_used.value).count('1')
return factor_count >= 2
@property
def has_dynamic_linking(self) -> bool:
"""
PSD2 Article 97(2): for payment transactions, the
authentication must be dynamically linked to the amount
and payee. This prevents an attacker from intercepting
an SCA session and redirecting the payment.
Implementation: the OTP or biometric challenge must display
the amount and payee, and the authentication code must be
cryptographically bound to these values.
"""
return (
self.transaction_amount is not None and
self.payee is not None
)
class SCAExemptionEngine:
"""
Evaluates whether a transaction qualifies for an SCA exemption.
PSD2 RTS defines several exemptions where SCA can be skipped:
- Low-value payments (< €30, with limits on cumulative amount/count)
- Trusted beneficiaries (payees whitelisted by the customer)
- Recurring payments (same amount, same payee)
- TRA (Transaction Risk Analysis) exemption based on fraud rates
These exemptions exist because SCA adds friction (3-8 seconds of
user interaction). In e-commerce, every second of friction costs
~7% conversion. So the regulation balances security against
commercial reality.
"""
def __init__(self):
self._low_value_count: dict[str, int] = {} # user -> count since last SCA
self._low_value_total: dict[str, float] = {} # user -> total since last SCA
self._trusted_beneficiaries: dict[str, set] = {} # user -> set of trusted payees
def evaluate_exemption(
self, user_id: str, amount: float, currency: str,
payee: str, is_recurring: bool, fraud_rate: float
) -> tuple[bool, str]:
"""
Returns (is_exempt, exemption_type) or (False, reason).
"""
# Exemption 1: Low-value transactions
if amount < 30.0 and currency == "EUR":
count = self._low_value_count.get(user_id, 0)
total = self._low_value_total.get(user_id, 0.0)
if count < 5 and total + amount < 100.0:
self._low_value_count[user_id] = count + 1
self._low_value_total[user_id] = total + amount
return True, "low_value"
# Exemption 2: Trusted beneficiary
if payee in self._trusted_beneficiaries.get(user_id, set()):
return True, "trusted_beneficiary"
# Exemption 3: Recurring payment (same amount, same payee)
if is_recurring:
return True, "recurring"
# Exemption 4: Transaction Risk Analysis (TRA)
# Fraud rate thresholds from PSD2 RTS:
# < €500: fraud rate must be < 0.13%
# < €250: fraud rate must be < 0.06%
# < €100: fraud rate must be < 0.01%
if amount < 500 and fraud_rate < 0.0013:
return True, "tra_500"
if amount < 250 and fraud_rate < 0.0006:
return True, "tra_250"
if amount < 100 and fraud_rate < 0.0001:
return True, "tra_100"
return False, "sca_required"
def reset_low_value_counters(self, user_id: str):
"""Reset after SCA is performed."""
self._low_value_count[user_id] = 0
self._low_value_total[user_id] = 0.0
def add_trusted_beneficiary(self, user_id: str, payee: str):
"""
Adding a trusted beneficiary ITSELF requires SCA.
This prevents attackers from adding themselves as trusted.
"""
if user_id not in self._trusted_beneficiaries:
self._trusted_beneficiaries[user_id] = set()
self._trusted_beneficiaries[user_id].add(payee)
OAuth 2.0 for Financial APIs
Open banking APIs use OAuth 2.0 as the authorization framework. But vanilla OAuth 2.0 was designed for social media APIs, not financial transactions. The security requirements are fundamentally different:
from dataclasses import dataclass
from urllib.parse import urlencode
import secrets
import hashlib
import base64
@dataclass
class OpenBankingConsent:
"""
A consent object represents the customer's permission for a
Third-Party Provider (TPP) to access specific account data
or initiate payments.
The consent lifecycle:
1. TPP requests consent via the bank's API
2. Bank redirects customer to its own authentication page
3. Customer authenticates (SCA) and reviews the consent
4. Customer approves → bank issues an authorization code
5. TPP exchanges the code for access + refresh tokens
6. TPP accesses data within the scope of the consent
7. Consent expires or is revoked by the customer
"""
consent_id: str
tpp_id: str # Third-Party Provider identifier
permissions: list[str] # e.g., ["ReadAccountsBasic", "ReadBalances"]
expiration: datetime
transaction_from: datetime | None # Date range for transaction history
transaction_to: datetime | None
status: str = "AwaitingAuthorisation"
class OAuthFlowForBanking:
"""
OAuth 2.0 Authorization Code flow with PKCE and PAR,
as required by UK Open Banking and Berlin Group.
"""
def __init__(
self, authorization_endpoint: str,
token_endpoint: str, client_id: str
):
self._auth_endpoint = authorization_endpoint
self._token_endpoint = token_endpoint
self._client_id = client_id
def initiate_authorization(
self, consent_id: str, redirect_uri: str, state: str
) -> tuple[str, str]:
"""
Build the authorization URL with PKCE.
PKCE (Proof Key for Code Exchange) prevents authorization
code interception attacks. Without PKCE, a malicious app
on the same device could intercept the redirect and steal
the authorization code.
Returns (authorization_url, code_verifier).
"""
# Generate PKCE code verifier (43-128 chars, unreserved chars)
code_verifier = secrets.token_urlsafe(64)
# Compute code challenge: BASE64URL(SHA256(code_verifier))
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
params = {
"response_type": "code",
"client_id": self._client_id,
"redirect_uri": redirect_uri,
"scope": f"openid accounts consent:{consent_id}",
"state": state,
"nonce": secrets.token_urlsafe(32),
"code_challenge": code_challenge,
"code_challenge_method": "S256",
# Request object signed by the TPP (FAPI requirement)
# This prevents parameter tampering in the authorization URL
}
url = f"{self._auth_endpoint}?{urlencode(params)}"
return url, code_verifier
def exchange_code(
self, authorization_code: str, code_verifier: str,
redirect_uri: str
) -> dict:
"""
Exchange the authorization code for tokens.
The code_verifier proves this is the same client that
initiated the authorization request (PKCE).
In FAPI, this request MUST use mTLS (mutual TLS) —
the client certificate is bound to the access token,
so a stolen token can't be used by a different client.
"""
token_request = {
"grant_type": "authorization_code",
"code": authorization_code,
"redirect_uri": redirect_uri,
"client_id": self._client_id,
"code_verifier": code_verifier,
}
# In production: send via HTTPS with client certificate (mTLS)
# The bank verifies:
# 1. The authorization code is valid and unused
# 2. The code_verifier matches the code_challenge
# 3. The client certificate matches the registered TPP
# 4. The redirect_uri matches the registered redirect
return {
"access_token": "eyJ...", # JWT or opaque
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": secrets.token_urlsafe(32),
"scope": "openid accounts",
# FAPI requires certificate-bound access tokens
"cnf": {
"x5t#S256": "hash_of_client_certificate"
}
}
Consent Lifecycle Management
Consent management is where open banking gets genuinely complex. Regulators require that customers can view, modify, and revoke consents at any time, and that banks provide a consent dashboard:
class ConsentManager:
"""
Manages the consent lifecycle for open banking.
Regulatory requirements:
- Consent must have a defined expiry (max 90 days for PSD2 AIS)
- Customer must be able to revoke at any time
- Re-authentication required every 90 days (SCA renewal)
- Consent scope must be as narrow as possible
- TPP must not request more access than needed
"""
def __init__(self):
self._consents: dict[str, OpenBankingConsent] = {}
self._access_log: list[dict] = []
def create_consent(
self, tpp_id: str, permissions: list[str],
valid_days: int = 90
) -> OpenBankingConsent:
"""
Create a new consent. Status starts as AwaitingAuthorisation.
Permissions follow a standardized taxonomy:
- ReadAccountsBasic / ReadAccountsDetail
- ReadBalances
- ReadTransactionsBasic / ReadTransactionsDetail
- ReadBeneficiariesBasic / ReadBeneficiariesDetail
"""
# Validate permissions against allowed set
allowed = {
"ReadAccountsBasic", "ReadAccountsDetail",
"ReadBalances",
"ReadTransactionsBasic", "ReadTransactionsDetail",
"ReadBeneficiariesBasic", "ReadBeneficiariesDetail",
"ReadProducts", "ReadStandingOrdersBasic",
}
invalid = set(permissions) - allowed
if invalid:
raise ValueError(f"Invalid permissions: {invalid}")
consent = OpenBankingConsent(
consent_id=secrets.token_urlsafe(16),
tpp_id=tpp_id,
permissions=permissions,
expiration=datetime.utcnow() + timedelta(days=valid_days),
transaction_from=None,
transaction_to=None,
)
self._consents[consent.consent_id] = consent
return consent
def authorize_consent(self, consent_id: str, sca_result: SCAResult):
"""
Mark consent as authorized after successful SCA.
The SCA result is recorded for audit trail — regulators
can request evidence that SCA was performed for every consent.
"""
consent = self._consents.get(consent_id)
if not consent:
raise ValueError(f"Consent not found: {consent_id}")
if not sca_result.is_sca_compliant:
raise ValueError("SCA requirements not met")
consent.status = "Authorised"
self._access_log.append({
"event": "consent_authorized",
"consent_id": consent_id,
"timestamp": datetime.utcnow().isoformat(),
"factors": str(sca_result.factors_used),
})
def check_access(
self, consent_id: str, requested_permission: str
) -> bool:
"""
Check if a consent grants the requested permission.
Called on every API request from the TPP. Must be fast
(< 1ms) as it's in the critical path of every API call.
"""
consent = self._consents.get(consent_id)
if not consent:
return False
if consent.status != "Authorised":
return False
if datetime.utcnow() > consent.expiration:
consent.status = "Expired"
return False
return requested_permission in consent.permissions
def revoke_consent(self, consent_id: str, revoked_by: str):
"""
Revoke a consent. Must be immediately effective.
After revocation:
- All access tokens associated with this consent are invalidated
- The TPP receives 403 on subsequent API calls
- The TPP must delete any cached data (regulatory requirement)
"""
consent = self._consents.get(consent_id)
if consent:
consent.status = "Revoked"
self._access_log.append({
"event": "consent_revoked",
"consent_id": consent_id,
"revoked_by": revoked_by,
"timestamp": datetime.utcnow().isoformat(),
})
The Security Stack
Open banking authentication uses a layered security model:
| Layer | Protocol | Purpose |
|---|---|---|
| Transport | mTLS | Mutual authentication between TPP and bank |
| Authorization | OAuth 2.0 + PKCE | Delegated access with consent |
| Identity | OpenID Connect | Customer identity verification |
| Message signing | JWS (RFC 7515) | Request/response integrity |
| API security | FAPI 2.0 | Financial-grade API security profile |
| SCA | Out-of-band challenge | Strong customer authentication |
Each layer addresses specific attacks. mTLS prevents impersonation. PKCE prevents code interception. JWS prevents message tampering. SCA prevents unauthorized access even if all other layers are compromised. A break at any single layer doesn’t compromise the system — defense in depth, applied to financial APIs.