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, and complete requests 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:

  1. Your page collects card data through Hosted Fields or Express Checkout, and the SDK returns a payment method token.
  2. Your frontend collects browser_info using the SDK's serializeBrowserInfo() helper.
  3. Your backend sends an authorize or purchase request including sca_provider_key and browser_info.
  4. If the transaction returns pending, your frontend starts a SpreedlyThreeDSLifecycle to run the authentication.
  5. The lifecycle fires onChallenge if the issuer requires a challenge, then onSuccess or onError when authentication resolves.
📘

Gateway Specific 3DS2 uses the same SpreedlyThreeDSLifecycle class 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_key is mutually exclusive with attempt_3dsecure. Use sca_provider_key for 3DS2 Global. attempt_3dsecure and three_ds_version apply only to Gateway Specific 3DS2. Field names must match casing and underscores exactly. browserInfo will 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

OptionRequiredDescription
transactionTokenYesTransaction token from the purchase response
hiddenIframeLocationYesElement ID for hidden iframes. Required, but unused in the 3DS2 Global flow
challengeIframeLocationYesElement ID where the challenge iframe is injected
callbacksYesEvent callbacks object
environmentKeyNoOptional, but highly recommended
challengeIframeClassesNoCSS classes applied to the challenge iframe

Callbacks

CallbackFires whenTypical action
onChallengeChallenge iframe is readyReveal the challenge container
onSuccessAuthentication succeededHide the modal, continue checkout
onErrorAuthentication failedHide 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, and onFinalizationTimeout are not fired in the 3DS2 Global flow, and the event's finalize function is unused. Unlike Gateway Specific 3DS2, 3DS2 Global does not require your backend to make a complete request. Those callbacks are documented in the Gateway Specific 3DS2 guide.

Lifestyle methods

lifecycle.start();  // Begin authentication
lifecycle.stop();   // Cancel and clean up

Content 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:

DirectiveRecommendationWhy
frame-srchttps:Allows the challenge iframe to load from any HTTPS origin, including whichever ACS the issuer selects
form-actionBroadened to matchThe challenge result posts back to the ACS. If this stays restricted, the frame renders but the challenge cannot complete
connect-srcBroadened to matchRequired 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 no managed_order_token and Forter is not involved, but they emit the same onChallenge, onSuccess, and onError callbacks as the production flow.

Troubleshooting

SymptomLikely causeFix
No managed_order_token in the purchase responsesca_provider_key missing from the backend requestAdd sca_provider_key to the purchase payload
SDK loads and tokenization works, but 3DS never startsForter script blocked by CSPAllow https://plugins.spreedly.com in script-src, then check the network tab
Challenge never appearschallengeIframeLocation doesn't match a DOM element IDVerify the element exists and the IDs match exactly
Challenge modal appears but is emptyThe container was not visible when the challenge firedReveal the modal inside onChallenge, before the iframe is injected
Challenge iframe is refused or blocked in the consoleACS domain not permitted by frame-srcSee the CSP section above. The console names the directive that blocked it
Challenge renders but never completesform-action or connect-src still restrictedThe frame loading doesn't mean the POST back to the ACS is permitted
Challenge renders blank with no CSP errorThe issuer's page sets X-Frame-Options or a restrictive frame-ancestorsControlled by the issuer; not resolvable through your own CSP
messages.failed_sca_authentication in onErrorThe cardholder failed the challengeShow a friendly message and let them retry
Authentication deniedSpreedly fails the transaction without calling the gatewayOffer a retry or an alternative payment method
3DS not availableThe card or issuer doesn't support 3DSConsider 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 iFrameCheckout 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.actionNamed 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.


Did this page help you?