3DS with the Checkout SDK
This guide walks you through implementing 3DS2 Global authentication when your payment page uses the Spreedly Checkout SDK, either headless (Hosted Fields) or as a drop-in component (Express Checkout).
The underlying 3DS2 Global concepts, Merchant Profile, SCA Provider, and the authentication flows, are the same regardless of how you collect card data. Those are covered in the Spreedly 3DS2 Global Guide. This page covers what's specific to the Checkout SDK.
If you haven't worked with 3DS2 Global before, start with Introduction to Spreedly 3DS2 Global for background, then return here.
If you're migrating from our legacy iFrame library, the 3DS API surface has changed. See Migrating from the legacy iFrame at the end of this guide for a direct mapping.
Prerequisites
Before starting, make sure you have:
- Checkout SDK version 1.0.0 or later. 3DS2 Global support has been available since the SDK's first release. Gateway Specific 3DS2 requires 1.1.0 or later.
- A Merchant Profile and SCA Provider configured. See the Spreedly 3DS2 Global Guide for setup, and the Merchant Profile API reference for field details.
- A gateway that supports 3DS. For testing, obtain a Spreedly Test gateway token, or create a real gateway in sandbox mode. See Testing your 3DS2 Global Integration.
- A verified non-3DS purchase. Confirm your test gateway can complete a standard purchase without 3DS first. This keeps later troubleshooting limited to 3DS-specific issues.
- A payment method token, generated through Hosted Fields, Express Checkout, the Payment Methods API, or retrieved from storage.
For security reasons, never make API requests from your frontend directly to Spreedly. The
authorize,purchase, andcompleterequests described below must all be made from your backend.
How 3DS fits into the Checkout SDK flow
The Checkout SDK's role is tokenizing the card. 3DS2 Global authentication happens after you have a payment method token, when your backend requests the transaction.
3DS2 Global routes authentication through Forter, a Spreedly partner. Forter handles device fingerprinting internally, which means a single integration works across every gateway that accepts third-party 3DS2 authentication data.
At a high level:
- Your page collects card data through Hosted Fields or Express Checkout, and the SDK returns a payment method token.
- Your frontend collects
browser_infousing theSDK's serializeBrowserInfo()helper. - Your backend sends an authorize or purchase request including
sca_provider_keyandbrowser_info. - If the transaction returns
pending, your frontend starts aSpreedlyThreeDSLifecycleto run the authentication. - The lifecycle fires
onChallengeif the issuer requires a challenge, thenonSuccessoronErrorwhen authentication resolves.
Gateway Specific 3DS2 uses the same
SpreedlyThreeDSLifecycleclass but a different backend payload and two additional callbacks. If your gateway performs its own 3DS rather than using a Spreedly SCA Provider, see Gateway Specific 3DS2.
Step 1: Include the Checkout SDK
Choose the script that matches your integration:
<!-- Hosted Fields (headless) -->
<script
src="https://core.spreedly.com/checkout/sdk/{version}/index.js"
integrity="sha384-{HASH_FROM_SRI_MANIFEST}"
crossorigin="anonymous">
</script>
<!-- Express Checkout (drop-in component) -->
<script
src="https://core.spreedly.com/checkout/elements/{version}/express-checkout.js"
integrity="sha384-{HASH_FROM_SRI_MANIFEST}"
crossorigin="anonymous">
</script>Pin {version} to a specific release for production. Always include the integrity attribute; SRI hashes are published with each release. See Securing iFrame for the script-integrity requirements that apply to any third-party script on a payment page under PCI DSS 4.0.
Loading the SDK exposes two globals used later in this guide: serializeBrowserInfo() and SpreedlyThreeDSLifecycle.
If you're already using the Checkout SDK for tokenization and are only adding 3DS now, no change is needed here. Continue to Step 2.
Step 2: Prepare your HTML
3DS needs two container elements on your page: a hidden one for device fingerprinting, and a visible one for the challenge form.
<style>
.hidden { display: none; }
#challenge-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border: 1px solid #ccc;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
</style>
<!-- Hidden container for device fingerprinting -->
<div id="device-fingerprint" style="display: none;"></div>
<!-- Modal container for the challenge form -->
<div id="challenge-modal" class="hidden">
<div id="challenge-container"></div>
</div>
The device fingerprint container is required by the SDK but is not used in the 3DS2 Global flow, because Forter performs fingerprinting internally. It is used in the Gateway Specific flow. Include the element either way.
Size your modal container to match the challenge window size you request in Step 3. If you request '04', for example, the modal should accommodate 600x400 pixels with a little room around it.
Step 3: Collect browser information
3DS2 Global requires browser_info on every transaction in PSD2 scope. The SDK provides serializeBrowserInfo() for this, available globally once the SDK script has loaded.
// Challenge window size, determining the size of the challenge iframe:
// '01' – 250x400 '02' – 390x300 '03' – 500x600
// '04' – 600x400 '05' – fullscreen
const challengeWindowSize = '04';
// The accept header from your server-rendered page.
// Inject this into your page template; it cannot be read from JavaScript.
const acceptHeader = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
const browserInfo = serializeBrowserInfo(challengeWindowSize, acceptHeader);Send browserInfo to your backend along with the payment method token:
const response = await fetch('/api/process-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
payment_method_token: '<payment_method_token>',
amount: 10000,
browser_info: browserInfo,
}),
}).then((r) => r.json());The accept header must come from the server-rendered page, since browsers don't expose it to JavaScript. Injecting it as a hidden form field and reading it from there is a common approach.
Step 4: Request the transaction from your backend
Include sca_provider_key and browser_info alongside your normal transaction fields:
POST /v1/gateways/<gateway_token>/purchase.json HTTP/1.1
Host: core.spreedly.com
Content-Type: application/json
{
"transaction": {
"sca_provider_key": "<sca_provider_key>",
"payment_method_token": "<payment_method_token>",
"amount": 10000,
"currency_code": "EUR",
"browser_info": "<browser_info_from_frontend>"
}
}
sca_provider_keyis mutually exclusive withattempt_3dsecure. Usesca_provider_keyfor 3DS2 Global.attempt_3dsecureandthree_ds_versionapply only to Gateway Specific 3DS2. Field names must match casing and underscores exactly.browserInfowill not work.
A 3DS2 Global transaction that requires authentication returns state: "pending" and a managed_order_token
{
"transaction": {
"state": "pending",
"token": "<transaction_token>",
"managed_order_token": "eyJ..."
}
}Return this response to your frontend.
Step 5: Handle the authentication flow
First, branch on the transaction state:
const { transaction } = response;
if (transaction.state === 'succeeded') {
// Frictionless: no challenge required, payment complete
window.location.href = '/payment-success';
} else if (transaction.state === 'pending') {
// Authentication required
start3DSLifecycle(transaction.token);
} else {
// Transaction failed
showError(transaction.message);
}Then create the lifecycle and start it:
let lifecycle = null;
function start3DSLifecycle(transactionToken) {
lifecycle = new SpreedlyThreeDSLifecycle({
// Required
transactionToken: transactionToken,
hiddenIframeLocation: 'device-fingerprint',
challengeIframeLocation: 'challenge-container',
// Optional, but highly recommended
environmentKey: '<your_environment_key>',
// Optional: CSS classes applied to the challenge iframe
challengeIframeClasses: 'custom-challenge-styles',
callbacks: {
onChallenge: (event) => {
// Challenge iframe is ready. Reveal your modal.
document.getElementById('challenge-modal').classList.remove('hidden');
},
onSuccess: (event) => {
document.getElementById('challenge-modal').classList.add('hidden');
window.location.href = '/payment-success';
},
onError: (event) => {
document.getElementById('challenge-modal').classList.add('hidden');
let message = event.context;
if (message === 'messages.failed_sca_authentication') {
message = 'Payment authentication failed. Please try again.';
}
showError(message);
},
},
});
lifecycle.start();
}Call
lifecycle.start()immediately after receiving a pending transaction. The 3DS specification requires authentication to begin within 30 seconds.
Configuration options
| Option | Required | Description |
|---|---|---|
| transactionToken | Yes | Transaction token from the purchase response |
| hiddenIframeLocation | Yes | Element ID for hidden iframes. Required, but unused in the 3DS2 Global flow |
| challengeIframeLocation | Yes | Element ID where the challenge iframe is injected |
| callbacks | Yes | Event callbacks object |
| environmentKey | No | Optional, but highly recommended |
| challengeIframeClasses | No | CSS classes applied to the challenge iframe |
Callbacks
| Callback | Fires when | Typical action |
|---|---|---|
| onChallenge | Challenge iframe is ready | Reveal the challenge container |
| onSuccess | Authentication succeeded | Hide the modal, continue checkout |
| onError | Authentication failed | Hide the modal, show an error and offer a retry |
Every callback receives the same event shape:
{
action: string, // 'challenge' | 'succeeded' | 'error'
context: object, // Transaction status object, or an error message string
token: string, // Transaction token
finalize: function, // Not used in the 3DS2 Global flow
response: object,
}
onDeviceFingerprint,onTriggerCompletion, andonFinalizationTimeoutare not fired in the 3DS2 Global flow, and the event'sfinalizefunction is unused. Unlike Gateway Specific 3DS2, 3DS2 Global does not require your backend to make acompleterequest. Those callbacks are documented in the Gateway Specific 3DS2 guide.
Lifestyle methods
lifecycle.start(); // Begin authentication
lifecycle.stop(); // Cancel and clean upContent security policy
A 3DS flow on the Checkout SDK involves three sets of origins, and they're commonly conflated. Allowing only the first is the most frequent cause of 3DS failing in production while tokenization keeps working.
The Checkout SDK
Content-Security-Policy:
script-src 'self' https://core.spreedly.com;
frame-src https://core.spreedly.com;
child-src https://core.spreedly.com;
connect-src 'self' https://core.spreedly.com;See Securing iFrame for the complete recommended policy, including the telemetry endpoints Spreedly uses for operational monitoring. Blocking telemetry won't break the SDK, but it prevents us from monitoring service health for your integration.
The 3DS provider
3DS2 Global additionally requires the Forter script, served from a Spreedly-controlled domain:
script-src https://plugins.spreedly.com;If this is blocked, the SDK loads and tokenization works, but 3DS authentication silently fails to initialize.
The issuer's challenge iframe
The challenge form itself is not served by Spreedly or Forter. It comes from the cardholder's issuing bank, through their Access Control Server (ACS), and the domain is selected dynamically per transaction based on the card being used.
Spreedly cannot provide an allowlist of ACS domains, and we recommend against building one. ACS domains are controlled by issuers and their 3DS vendors, number in the hundreds, and change over time as issuers migrate between vendors. A static list will be incomplete the day it ships, and as it ages it will silently decline transactions for real cardholders. This is a property of the 3DS protocol rather than of Spreedly's implementation, so it applies equally to any payments provider.
On the page hosting your checkout and challenge, and scoped to that page rather than site-wide, we recommend:
| Directive | Recommendation | Why |
|---|---|---|
| frame-src | https: | Allows the challenge iframe to load from any HTTPS origin, including whichever ACS the issuer selects |
| form-action | Broadened to match | The challenge result posts back to the ACS. If this stays restricted, the frame renders but the challenge cannot complete |
| connect-src | Broadened to match | Required for device fingerprinting during authentication |
If your security requirements don't permit https:, you can layer wildcards for major 3DS vendors on top of a permissive fallback. A vendor list should never be the entire policy on its own.
Testing
Use Spreedly's test SCA Provider to exercise each flow without involving a real issuer. See Testing your 3DS2 Global Integration for the test_scenario values that trigger frictionless success, frictionless failure, and challenge flows.
As of SDK version 1.4.1, transactions created with the test SCA Provider (
sca_provider_type: "test") run the 3DS Global flow directly. These transactions carry nomanaged_order_tokenand Forter is not involved, but they emit the sameonChallenge,onSuccess, andonErrorcallbacks as the production flow.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No managed_order_token in the purchase response | sca_provider_key missing from the backend request | Add sca_provider_key to the purchase payload |
| SDK loads and tokenization works, but 3DS never starts | Forter script blocked by CSP | Allow https://plugins.spreedly.com in script-src, then check the network tab |
| Challenge never appears | challengeIframeLocation doesn't match a DOM element ID | Verify the element exists and the IDs match exactly |
| Challenge modal appears but is empty | The container was not visible when the challenge fired | Reveal the modal inside onChallenge, before the iframe is injected |
| Challenge iframe is refused or blocked in the console | ACS domain not permitted by frame-src | See the CSP section above. The console names the directive that blocked it |
| Challenge renders but never completes | form-action or connect-src still restricted | The frame loading doesn't mean the POST back to the ACS is permitted |
| Challenge renders blank with no CSP error | The issuer's page sets X-Frame-Options or a restrictive frame-ancestors | Controlled by the issuer; not resolvable through your own CSP |
| messages.failed_sca_authentication in onError | The cardholder failed the challenge | Show a friendly message and let them retry |
| Authentication denied | Spreedly fails the transaction without calling the gateway | Offer a retry or an alternative payment method |
| 3DS not available | The card or issuer doesn't support 3DS | Consider retrying without 3DS if your business rules allow it |
For more, see the 3DS2 Global FAQs in the Help Center.
Migrating from the legacy iFrame
If you're moving an existing iFrame 3DS integration to the Checkout SDK, the flow is unchanged but the API surface has moved. Your backend requires no changes.
| Legacy iFrame | Checkout SDK |
|---|---|
| Spreedly.ThreeDS.serialize(challengeWindowSize, acceptHeader) | serializeBrowserInfo(challengeWindowSize, acceptHeader) – renamed, same signature |
| new Spreedly.ThreeDS.Lifecycle({ ... }) | new SpreedlyThreeDSLifecycle({ ... }) – top-level class, no Spreedly.ThreeDS namespace |
| Spreedly.on('3ds:status', handler) with a switch on event.action | Named callbacks on the constructor: callbacks: { onChallenge, onSuccess, onError } |
| lifecycle.start() | lifecycle.start() – unchanged |
| — | lifecycle.stop() – new cancel and cleanup helper |
See Upgrading Your Checkout Experience for the broader migration, and the SDK migration guide for full API-level parity details.
If you have questions not covered in this guide, please contact Spreedly support for assistance.
Updated about 2 hours ago

