Skip to main content
digital payment systems cryptography banking protocols and blockchain internals

Authentication Protocols in Open Banking

9 min read Chapter 13 of 21

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:

  1. Knowledge: something the customer knows (PIN, password)
  2. Possession: something the customer has (phone, card, hardware token)
  3. 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 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:

LayerProtocolPurpose
TransportmTLSMutual authentication between TPP and bank
AuthorizationOAuth 2.0 + PKCEDelegated access with consent
IdentityOpenID ConnectCustomer identity verification
Message signingJWS (RFC 7515)Request/response integrity
API securityFAPI 2.0Financial-grade API security profile
SCAOut-of-band challengeStrong 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.