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

3D Secure 2.0 Protocol Internals

11 min read Chapter 15 of 21

3D Secure 2.0 Protocol Internals

3D Secure 2.0 (3DS2) is the card networks’ answer to e-commerce fraud. It shifts authentication liability from the merchant to the issuing bank — but only if the merchant correctly implements the protocol. Getting the implementation wrong means you absorb the fraud liability AND add friction to the checkout flow.

3DS2 Authentication Flow

The diagram shows the complete message flow between the six participants in a 3DS2 transaction. The critical design decision is at step 4: does the issuer’s ACS request a challenge (step-up authentication) or grant frictionless approval?

Protocol Architecture

The 3DS2 ecosystem involves six entities:

  1. Cardholder — the person making the purchase
  2. Merchant — the e-commerce site or app
  3. 3DS Server (3DSS) — the merchant-side protocol handler, usually provided by the acquirer or PSP
  4. Directory Server (DS) — operated by the card network (Visa, Mastercard), routes messages
  5. Access Control Server (ACS) — operated by or for the issuing bank, performs authentication
  6. 3DS SDK — client library embedded in the merchant’s app or website (collects device data)
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
import hashlib
import json
import secrets

class TransactionStatus(Enum):
    """3DS2 transaction status values."""
    AUTHENTICATED = "Y"          # Authentication successful
    NOT_AUTHENTICATED = "N"      # Authentication failed
    CHALLENGE_REQUIRED = "C"     # Challenge required
    ATTEMPT = "A"                # Issuer not participating, proof of attempt
    REJECTED = "R"               # Authentication rejected
    INFORMATIONAL = "I"          # Informational only (non-payment)
    DECOUPLED = "D"              # Decoupled authentication in progress

class DeviceChannel(Enum):
    APP = "01"      # Mobile app (native SDK)
    BROWSER = "02"  # Browser-based
    THREE_RI = "03" # 3DS Requestor Initiated (recurring, MIT)

@dataclass
class AuthenticationRequest:
    """
    AReq — Authentication Request message.
    
    Sent from the 3DS Server to the Directory Server, which routes
    it to the appropriate ACS based on the card range.
    
    Contains ~150 data elements. The most critical ones for risk
    assessment are highlighted below.
    """
    # Transaction identification
    threeds_server_trans_id: str     # UUID generated by 3DS Server
    ds_trans_id: str = ""           # Assigned by Directory Server
    acs_trans_id: str = ""          # Assigned by ACS
    
    # Transaction details
    purchase_amount: str = ""       # Amount in minor units (e.g., "10000" for $100.00)
    purchase_currency: str = ""     # ISO 4217 numeric (e.g., "840" for USD)
    purchase_date: str = ""         # YYYYMMDDHHmmss
    
    # Card details
    acct_number: str = ""           # PAN (encrypted in transit)
    
    # Device channel
    device_channel: str = "02"      # "01"=App, "02"=Browser, "03"=3RI
    
    # Merchant risk indicator
    ship_indicator: str = ""        # "01"=ship to billing, "02"=ship to verified address
    delivery_timeframe: str = ""    # "01"=electronic delivery, "02"=same day
    reorder_items_ind: str = ""     # "01"=first time, "02"=reorder
    
    # Cardholder account information (critical for risk assessment)
    ch_acc_age_ind: str = ""        # Account age: "01"=guest, "02"=< 30 days
    ch_acc_change_ind: str = ""     # Last change: "01"=current txn, "02"=< 30 days
    nb_purchase_account: str = ""   # Number of purchases in last 6 months
    payment_acc_age: str = ""       # When this card was added to the account
    
    # Browser/device data (collected by 3DS SDK)
    browser_accept_header: str = ""
    browser_ip: str = ""
    browser_java_enabled: bool = False
    browser_language: str = ""
    browser_color_depth: str = ""
    browser_screen_height: str = ""
    browser_screen_width: str = ""
    browser_tz: str = ""
    browser_user_agent: str = ""
    
    # Device fingerprint from SDK
    sdk_app_id: str = ""
    sdk_enc_data: str = ""         # Encrypted device data
    sdk_reference_number: str = ""
    sdk_trans_id: str = ""
    
    # Message metadata
    message_version: str = "2.2.0"
    message_type: str = "AReq"

@dataclass
class AuthenticationResponse:
    """
    ARes — Authentication Response message.
    
    Returned from the ACS (via DS) to the 3DS Server.
    Contains the authentication decision.
    """
    threeds_server_trans_id: str
    acs_trans_id: str
    ds_trans_id: str
    
    # The critical field — the authentication decision
    trans_status: str              # Y, N, C, A, R, I, D
    
    # If trans_status == "Y" (frictionless approval)
    authentication_value: str = "" # CAVV — the proof of authentication
    eci: str = ""                  # E-Commerce Indicator ("05"=full auth, "06"=attempt)
    
    # If trans_status == "C" (challenge required)
    acs_url: str = ""              # URL for the challenge iframe/redirect
    acs_challenge_mandated: str = "" # "Y" if challenge cannot be bypassed
    
    # Risk assessment details (for merchant analytics)
    trans_status_reason: str = ""  # Why authentication failed/challenged
    
    message_type: str = "ARes"
    message_version: str = "2.2.0"

The Authentication Decision

The ACS makes the authentication decision based on risk analysis. This is the most commercially important part of the protocol — a high challenge rate kills conversion, while a low challenge rate increases fraud:

class ACSRiskEngine:
    """
    Risk-based authentication decision engine for a 3DS2 ACS.
    
    The ACS must balance three competing pressures:
    1. Fraud prevention (challenge suspicious transactions)
    2. Customer experience (minimize challenges — target < 5% challenge rate)
    3. PSD2 SCA compliance (challenge when SCA exemption doesn't apply)
    
    The engine scores each transaction on multiple risk dimensions
    and compares against configurable thresholds.
    """
    
    def __init__(self):
        self._risk_weights = {
            "device_trust": 0.25,
            "behavioral": 0.20,
            "transaction": 0.20,
            "account": 0.15,
            "merchant": 0.10,
            "network": 0.10,
        }
    
    def evaluate(
        self, areq: AuthenticationRequest, 
        cardholder_history: dict
    ) -> tuple[TransactionStatus, str]:
        """
        Evaluate an authentication request and return a decision.
        
        Returns (status, reason).
        """
        risk_score = self._compute_risk_score(areq, cardholder_history)
        
        # Threshold-based decision
        if risk_score < 20:
            return TransactionStatus.AUTHENTICATED, "low_risk"
        elif risk_score < 50:
            # Medium risk — check SCA exemption
            if self._qualifies_for_exemption(areq, cardholder_history):
                return TransactionStatus.AUTHENTICATED, "sca_exempt"
            return TransactionStatus.CHALLENGE_REQUIRED, "medium_risk"
        elif risk_score < 80:
            return TransactionStatus.CHALLENGE_REQUIRED, "high_risk"
        else:
            return TransactionStatus.REJECTED, "very_high_risk"
    
    def _compute_risk_score(
        self, areq: AuthenticationRequest, history: dict
    ) -> float:
        """
        Compute a composite risk score (0-100).
        
        Higher score = higher risk = more likely to challenge.
        """
        scores = {}
        
        # Device trust: is this a recognized device?
        scores["device_trust"] = self._score_device(areq, history)
        
        # Behavioral: does the transaction pattern match the cardholder?
        scores["behavioral"] = self._score_behavior(areq, history)
        
        # Transaction: is the amount unusual?
        scores["transaction"] = self._score_transaction(areq, history)
        
        # Account: how old is the account? Recently changed?
        scores["account"] = self._score_account(areq)
        
        # Merchant: is this a high-risk merchant category?
        scores["merchant"] = self._score_merchant(areq)
        
        # Network: IP geolocation, velocity, known fraud lists
        scores["network"] = self._score_network(areq)
        
        # Weighted composite
        total = sum(
            scores[k] * self._risk_weights[k] for k in scores
        )
        
        return min(100, max(0, total))
    
    def _score_device(self, areq: AuthenticationRequest, history: dict) -> float:
        """
        Device fingerprint matching.
        
        The 3DS SDK collects extensive device data:
        - Screen resolution, color depth, timezone
        - Installed fonts, browser plugins
        - Hardware concurrency, device memory
        - WebGL renderer string
        
        A recognized device scores low risk. A new device from
        an unusual location scores high risk.
        """
        known_devices = history.get("known_devices", [])
        
        device_fingerprint = hashlib.sha256(
            f"{areq.browser_user_agent}"
            f"{areq.browser_screen_width}x{areq.browser_screen_height}"
            f"{areq.browser_color_depth}"
            f"{areq.browser_tz}".encode()
        ).hexdigest()
        
        if device_fingerprint in known_devices:
            return 10.0  # Known device
        return 60.0  # Unknown device
    
    def _score_behavior(self, areq: AuthenticationRequest, history: dict) -> float:
        """Purchase pattern analysis."""
        avg_amount = history.get("avg_purchase_amount", 0)
        purchase_amount = int(areq.purchase_amount) / 100 if areq.purchase_amount else 0
        
        if avg_amount > 0 and purchase_amount > avg_amount * 3:
            return 70.0  # Unusually high amount
        return 20.0
    
    def _score_transaction(self, areq: AuthenticationRequest, history: dict) -> float:
        """Transaction-specific risk indicators."""
        score = 0.0
        
        # Electronic delivery + new account = higher risk (digital goods fraud)
        if areq.delivery_timeframe == "01" and areq.ch_acc_age_ind == "02":
            score += 40.0
        
        # Shipping to non-billing address
        if areq.ship_indicator == "03":  # Different from billing
            score += 20.0
        
        return min(100, score)
    
    def _score_account(self, areq: AuthenticationRequest) -> float:
        """Account age and change recency."""
        if areq.ch_acc_age_ind == "01":  # Guest/no account
            return 50.0
        if areq.ch_acc_age_ind == "02":  # < 30 days
            return 40.0
        if areq.ch_acc_change_ind == "01":  # Changed during this transaction
            return 60.0
        return 10.0
    
    def _score_merchant(self, areq: AuthenticationRequest) -> float:
        """Merchant category risk assessment."""
        # Simplified — in production, use MCC (Merchant Category Code)
        return 20.0  # Default medium risk
    
    def _score_network(self, areq: AuthenticationRequest) -> float:
        """Network-level risk signals."""
        # Check against known fraud IP ranges, VPN detection, etc.
        return 20.0  # Default
    
    def _qualifies_for_exemption(
        self, areq: AuthenticationRequest, history: dict
    ) -> bool:
        """Check PSD2 SCA exemptions."""
        amount = int(areq.purchase_amount) / 100 if areq.purchase_amount else 0
        
        # Low-value exemption
        if amount < 30:
            return True
        
        # TRA exemption (issuer's fraud rate must be below threshold)
        issuer_fraud_rate = history.get("issuer_fraud_rate", 0.01)
        if amount < 500 and issuer_fraud_rate < 0.0013:
            return True
        
        return False

CAVV: The Cryptographic Proof

When the ACS approves authentication (frictionless or after challenge), it generates a Cardholder Authentication Verification Value (CAVV) — a cryptographic proof that authentication occurred:

import hmac

class CAVVGenerator:
    """
    Generate the CAVV — the cryptographic proof of authentication.
    
    The CAVV is sent to the merchant and included in the authorization
    request to the issuer. The issuer verifies the CAVV to confirm
    that 3DS authentication was performed.
    
    Format: 20-byte value (Visa calls it CAVV, Mastercard calls it AAV)
    
    The CAVV binds the authentication to:
    - The specific transaction (amount, currency, merchant)
    - The ACS that performed the authentication
    - A timestamp (prevents replay)
    
    If the merchant modifies the transaction amount after authentication,
    the CAVV verification at the issuer will fail, and the liability
    shifts back to the merchant.
    """
    
    def __init__(self, acs_key: bytes):
        """
        acs_key: the ACS's HMAC key, shared with the card network.
        """
        self._key = acs_key
    
    def generate(
        self, acs_trans_id: str, purchase_amount: str,
        purchase_currency: str, merchant_name: str
    ) -> bytes:
        """
        Generate a CAVV for a successful authentication.
        
        The CAVV is computed as:
        HMAC-SHA256(acs_key, transaction_data), truncated to 20 bytes.
        
        The transaction data includes all fields that must be
        protected from modification after authentication.
        """
        # Construct the data to authenticate
        auth_data = (
            f"{acs_trans_id}|"
            f"{purchase_amount}|"
            f"{purchase_currency}|"
            f"{merchant_name}|"
            f"{int(datetime.utcnow().timestamp())}"
        ).encode()
        
        # HMAC with the ACS's secret key
        mac = hmac.new(self._key, auth_data, hashlib.sha256).digest()
        
        # Truncate to 20 bytes (per EMVCo specification)
        return mac[:20]
    
    def verify(
        self, cavv: bytes, acs_trans_id: str,
        purchase_amount: str, purchase_currency: str,
        merchant_name: str, timestamp_window: int = 300
    ) -> bool:
        """
        Verify a CAVV at the issuer side.
        
        The issuer receives the CAVV in the authorization message
        (DE48 in ISO 8583 or equivalent) and verifies it using
        the shared ACS key.
        
        A failed verification means either:
        1. The transaction was tampered with after authentication
        2. The CAVV was forged
        3. The wrong ACS key was used
        
        In all cases, the issuer should decline and investigate.
        """
        expected = self.generate(
            acs_trans_id, purchase_amount,
            purchase_currency, merchant_name
        )
        
        return hmac.compare_digest(cavv, expected)

Challenge Flow Implementation

When the ACS requests a challenge, the cardholder must interact with the issuer’s authentication UI:

@dataclass
class ChallengeRequest:
    """
    CReq — Challenge Request message.
    
    Sent from the 3DS SDK (in the cardholder's browser or app)
    to the ACS URL received in the ARes.
    
    The SDK renders an iframe (browser) or native UI (app)
    pointing to the ACS URL, and the cardholder interacts
    with the issuer's challenge flow.
    """
    acs_trans_id: str
    challenge_window_size: str  # "01"=250x400 to "05"=full screen
    message_type: str = "CReq"
    message_version: str = "2.2.0"
    
    # For OTP challenges
    challenge_data_entry: str = ""  # The OTP entered by the cardholder

@dataclass
class ChallengeResponse:
    """
    CRes — Challenge Response message.
    
    Returned from the ACS after the cardholder completes (or fails)
    the challenge.
    """
    acs_trans_id: str
    trans_status: str          # "Y" (success) or "N" (failure)
    authentication_value: str = ""  # CAVV — only present if Y
    
    # Challenge completion indicator
    challenge_completion_ind: str = "Y"  # "Y"=final, "N"=more steps
    
    message_type: str = "CRes"

class ChallengeOrchestrator:
    """
    Manages the challenge flow between the 3DS SDK and ACS.
    
    Challenge types:
    - OTP via SMS or push notification
    - Biometric (fingerprint, face) via banking app
    - Out-of-band (approve in mobile banking app)
    - Knowledge-based (security questions)
    
    The SDK polls or uses WebSocket to detect challenge completion,
    then sends the result back to the 3DS Server.
    """
    
    def initiate_challenge(
        self, acs_url: str, ares: AuthenticationResponse
    ) -> dict:
        """
        Initiate the challenge flow.
        
        Returns rendering instructions for the SDK.
        """
        return {
            "action": "render_challenge",
            "acs_url": acs_url,
            "acs_trans_id": ares.acs_trans_id,
            "method": "POST",
            "params": {
                "threeDSServerTransID": ares.threeds_server_trans_id,
                "acsTransID": ares.acs_trans_id,
                "messageType": "CReq",
                "messageVersion": ares.message_version,
                "challengeWindowSize": "02",  # 390x400 pixels
            },
            "timeout_seconds": 300,  # 5-minute challenge timeout
        }
    
    def process_challenge_result(
        self, cres: ChallengeResponse
    ) -> dict:
        """
        Process the challenge result and prepare for authorization.
        """
        if cres.trans_status == "Y":
            return {
                "authenticated": True,
                "cavv": cres.authentication_value,
                "eci": "05",  # Full 3DS authentication
                "liability": "issuer",
            }
        else:
            return {
                "authenticated": False,
                "eci": "07",  # Authentication failed
                "liability": "merchant",
                "recommendation": "proceed_at_own_risk",
            }

Protocol Timing Requirements

3DS2 specifies strict timing requirements to minimize checkout friction:

PhaseMaximum DurationTypical Duration
AReq → ARes (frictionless)10 seconds1-3 seconds
Challenge rendering30 seconds2-5 seconds
Cardholder challenge completion5 minutes15-30 seconds
Total frictionless flow10 seconds1-3 seconds
Total challenge flow~5.5 minutes20-60 seconds

These timings matter because payment conversion drops ~7% for every second added to checkout. A frictionless 3DS2 flow adds only 1-3 seconds — barely noticeable. A challenge flow adds 20-60 seconds and causes 10-15% abandonment. The commercial incentive to achieve a high frictionless rate (target: >95%) drives issuers to invest heavily in their risk engines.