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
When the customer is ready to pay, your server creates the charge intent (or transfer) and returns the client_secret to the browser. From frame-js, call confirmCardPayment with that secret — this is where 3DS triggers if Frame's engine decides it's needed.
const frame = await Frame.init('pk_sandbox_your_publishable_key');
const { chargeIntent } = await frame.confirmCardPayment(clientSecret);
if (chargeIntent.status === 'succeeded') {
// Charge cleared. 3DS either wasn't required or completed successfully.
showSuccess();
} else if (chargeIntent.status === 'failed') {
// 3DS authentication failed, or the charge declined post-authentication.
showFailure(chargeIntent.failure_code);
}
What happens inside confirmCardPayment:
- If Frame decides no 3DS is needed, the charge proceeds directly.
confirmCardPaymentresolves withstatus: succeeded(orfailedfor a non-3DS decline). - If Frame decides 3DS is needed, the SDK opens the issuer's authentication UI in a modal (or redirects, depending on the issuer's setup). The customer completes the challenge. The SDK waits for the result, then proceeds with the charge attempt.
You don't see any of this branching in your code — confirmCardPayment handles it. You just check the final status.
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 |
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: confirmCardPayment 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 charge intent 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