3D Secure 2.0 Protocol Internals
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.
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:
- Cardholder — the person making the purchase
- Merchant — the e-commerce site or app
- 3DS Server (3DSS) — the merchant-side protocol handler, usually provided by the acquirer or PSP
- Directory Server (DS) — operated by the card network (Visa, Mastercard), routes messages
- Access Control Server (ACS) — operated by or for the issuing bank, performs authentication
- 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:
| Phase | Maximum Duration | Typical Duration |
|---|---|---|
| AReq → ARes (frictionless) | 10 seconds | 1-3 seconds |
| Challenge rendering | 30 seconds | 2-5 seconds |
| Cardholder challenge completion | 5 minutes | 15-30 seconds |
| Total frictionless flow | 10 seconds | 1-3 seconds |
| Total challenge flow | ~5.5 minutes | 20-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.