Handle 3D Secure
3D Secure is the network-level authentication protocol that lets card issuers verify the cardholder is present before a charge. Frame triggers it automatically when the rules require it; this guide covers the integration-side handling — how to surface the authentication UI to the customer through frame-js, react to the outcome, and verify the cryptogram landed for dispute defense.
The most important thing: you don't request 3DS explicitly. Frame's engine decides per-transaction. Your job is to handle the case where it triggers, not to trigger it yourself.
Prerequisites
| Requirement | Details |
|---|---|
| Frame publishable key | For frame-js initialization on the client. See Install. |
| Frame secret key | For server-side charge creation. |
| frame-js loaded | 3DS UI rendering requires frame-js. Without it, you'd have to render the issuer's authentication iframe yourself. |
| Customer with a card | Either attached during onboarding or collected via frame-js's Card element. |
1. Confirm the charge from frame-js
Your server creates the account and payment method, then mints a checkout session with the locked amount (POST /v1/checkout_sessions) and returns client_secret (chk_sess_…) to the browser. The browser creates the transfer and confirms it. confirmTransfer is where 3DS runs if Frame's engine decides it's needed.
const frame = await Frame.init('pk_sandbox_your_publishable_key');
const transfer = await frame.createTransfer(
{ source: { payment_method_id: paymentMethodId } },
checkoutClientSecret,
);
const result = await frame.confirmTransfer(transfer.id, checkoutClientSecret);
if (!result.hasError && result.transfer?.payment?.status === 'succeeded') {
showSuccess();
} else {
showFailure(result.error?.message);
}
What happens inside confirmTransfer:
- If Frame decides no 3DS is needed, the charge proceeds. The result's payment status is
succeeded, or the result has an error for a non-3DS decline. - If Frame decides 3DS is needed, the payment status comes back
requires_3d_securewithnext_action.use_frame_sdk.source. The SDK opens the issuer's authentication UI. The customer completes the challenge. The SDK polls the transfer and resolves with the final status.
You don't branch on that in your code. confirmTransfer handles it. Check result.hasError and result.transfer.payment.status.
A V1 charge intent still confirms with frame.confirmCardPayment(clientSecret). That helper accepts only a ci_ secret. A Transfers V2 checkout secret (chk_sess_…) is not a charge-intent secret, so pass it to createTransfer and confirmTransfer.
2. Handle the result states
Three outcomes to wire for:
| Result | What it means | Customer-facing UX |
|---|---|---|
succeeded | 3DS either wasn't needed or completed; charge cleared. | Standard success path. Show order confirmation. |
failed with failure_code: authentication_required | 3DS was triggered but didn't complete (customer abandoned the challenge, timeout, network failure). | "Authentication wasn't completed — please try again." Allow a retry. |
failed with failure_code: <other> | 3DS may have completed but the charge declined for a different reason (e.g., insufficient_funds). | Surface per the decline codes guidance for that specific code. |
If failure_code is fraudulent, lost_card, stolen_card, or any risk-category code, surface as a generic error — don't disclose the underlying reason. See decline codes for the customer-facing display guidance.
3. Verify the cryptogram landed (optional)
When 3DS succeeds, Frame stores the 3DS cryptogram on the payment method. You don't need to do anything with it directly — it's automatically attached to future dispute evidence — but you can verify it's there by retrieving the payment method:
curl --request GET \
--url https://api.framepayments.com/v1/payment_methods/<payment_method_id> \
--header "Authorization: Bearer $FRAME_SECRET_KEY"
A cryptogram-bearing payment method is the most important asset for dispute defense on fraud-coded chargebacks — issuer-authenticated transactions shift liability for fraud disputes to the issuer.
If you want to maximize cryptogram coverage on a customer's card, consider running card verification when the card is first attached — that flow runs 3DS at link time and stores the cryptogram, defending all future charges on the card even when those charges don't run 3DS themselves.
Testing in sandbox
Use Frame's 3DS test cards to verify each outcome. Pair any of these with the sandbox key:
| Card | Behavior |
|---|---|
4000000000003220 | 3DS required; authentication must succeed |
4111110116638870 | 3DS required; frictionless (no customer prompt) |
4111111738973695 | 3DS frictionless then declines with card_not_enrolled |
4000008400001629 | 3DS required then declines with generic_decline |
4111110148486413 | Visa not enrolled in 3DS; authentication fails with card_not_enrolled, no challenge shown |
5555558824815612 | Mastercard not enrolled in 3DS; authentication fails with card_not_enrolled, no challenge shown |
Run each card through your checkout flow. Confirm your application handles success, in-flight authentication, frictionless authentication, and post-authentication declines correctly.
Common variations
Off-session charges (subscription renewals). 3DS typically doesn't trigger on off-session payments because the cardholder isn't there to authenticate. If you run card verification at signup, the stored cryptogram defends the off-session charges even though they don't run 3DS at charge time.
Custom UI without frame-js. If you must render the authentication UI yourself, Frame returns the ACS URL and challenge parameters on the charge intent. You'd render those in an iframe or popup, then notify Frame when the customer completes. This is significantly more work than using frame-js's built-in modal — only do it if you have a compelling UX reason.
Regulatory mandates. Some jurisdictions (PSD2/SCA in EU/UK, parts of LATAM and India) require Strong Customer Authentication on many transactions. 3DS is how Frame meets those requirements; the engine triggers it automatically for transactions in those jurisdictions. You don't have to opt in.
Gotchas
Symptom: confirmTransfer hangs and never resolves. Why: the customer started a 3DS challenge and didn't complete it — they closed the tab, walked away, or hit a popup blocker. Fix: surface a timeout in your UI after a reasonable window (~5 minutes is typical). Cancel the transfer on your server if you want to free up the payment method for retry.
Symptom: 3DS challenge appears for every transaction during testing, but you expected only some. Why: you're using a test card configured to always require 3DS (like 4000000000003220). Fix: mix in a non-3DS-required test card (e.g., 4242424242424242) to verify your handling of both paths.
Symptom: the customer authenticated, the charge succeeded, but a later dispute on the same card still went against you. Why: the cryptogram defends fraud-category disputes, not other categories. A product_not_received dispute won't be defended by a stored cryptogram regardless of how well-authenticated the original charge was. Fix: track dispute outcomes by reason category to know what your defense strategies are actually winning on.
Next steps
- 3D Secure concept for the protocol model + liability shift
- Accept a payment for the full charge flow with frame-js
- Disputes for what 3DS cryptograms defend against
- Card verification for capturing a cryptogram at link time for long-term defense