3-D Secure 2: what the backend has to do
Authentication is not a capture. Which states your API has to persist, why the shopper return and the webhook will race, and what not to infer from a redirect.
3-D Secure 2 is an authentication step in front of authorisation. The cardholder proves they are present, sometimes with a challenge at their bank and sometimes with no challenge at all, and only then does anyone talk about capturing money.
The bug is treating the redirect as the outcome. The browser left your site. That is not a payment. It is a wait.
EMVCo publishes the protocol. Gateways wrap it. Your backend’s job is the wrapper: start authentication, persist the result the gateway actually returns, and refuse to capture on a guess. The shopper coming back to your return URL and the webhook announcing the same result are two deliveries of one fact, and they will arrive in either order. That race is the same problem as out-of-order webhooks.
What you need
- A gateway integration that performs 3-D Secure, not a form that posts the card number to your server. If the PAN touches you, this is no longer a checkout-page problem; it is a PCI problem, covered in PCI SAQ A.
- Somewhere durable to put the authentication state before you redirect the shopper away. The browser is not that store.
- A handler that can be called twice, once by the return URL and once by the webhook, and move the payment exactly once.
The states that matter
EMV 3-D Secure reports a transaction status. The values the backend has to branch on, from the protocol rather than from any one gateway’s marketing page:
| Status | Meaning for your API | Capture? |
|---|---|---|
C |
Challenge required. The shopper is not done. | No. Redirect them on. |
Y |
Authentication succeeded. | Yes, subject to your own authorisation step. |
A |
Attempts processing. The directory tried; a full authentication did not complete. | Only if you have decided, in writing, to accept attempts. Default is no. |
N |
Failed or denied. | No. |
R |
Rejected. The issuer wants this stopped, not retried. | No. |
U |
Could not be performed. | No, unless your risk policy explicitly allows an unauthenticated attempt. |
I |
Informational. Data was sent; the outcome is not an authentication. | No. |
D, decoupled challenge, means the cardholder will authenticate outside this
browser session. You do not have a result yet. Do not capture, and do not
assume they abandoned the payment because they never returned to your site.
Gateways sometimes collapse these into authenticated / failed / pending.
Store their field and the raw status if they give it to you. A boolean named
success is how A and Y become the same row, and how a dispute later has
nothing to point at.
What the return URL is allowed to do
The shopper’s browser hits your return URL. That request is not authenticated by the issuer. Anyone can open it. It is a signal to go and ask the gateway what the payment’s state is, or to wait until the signed webhook arrives. It is not itself proof.
public async Task<IResult> Returned(string paymentId, CancellationToken ct)
{
var payment = await db.GetAsync(paymentId, ct);
if (payment is null) return Results.NotFound();
// Re-read from the gateway. Do not trust query-string flags.
var remote = await gateway.FetchAsync(payment.ProviderId, ct);
await ApplyAsync(payment, remote.TransStatus, ct);
return remote.TransStatus switch
{
"Y" => Results.Redirect($"/orders/{payment.OrderId}/confirmed"),
"C" or "D" => Results.Redirect($"/orders/{payment.OrderId}/pending"),
_ => Results.Redirect($"/orders/{payment.OrderId}/failed"),
};
}
ApplyAsync has to be the same function the webhook calls, and it has to be
safe when both run. One of them will lose the race. The loser must see the
state already moved and stop. Capture lives inside that function, after the
transition to an acceptable status, not in the controller that happened to
run first.
Do not capture on the redirect and also capture on the webhook. That is a double capture, and idempotency on the capture call is what makes the second one harmless if you do wire both by mistake.
Frictionless is still asynchronous
A frictionless flow returns Y without showing a challenge. It is still a
result you persist. It is not “3-D Secure was skipped”, and it is not a reason
to take a different code path that forgets to store the status. Disputes ask
what evidence you held. “We didn’t bother because there was no challenge” is
not evidence.
Liability for fraud, when it shifts at all, shifts under scheme rules on the
back of that evidence. It is not a flag you set. Store what the gateway
returned — status, and the ECI value if they include one — and leave the
liability question to the scheme and your acquirer. Inventing an ECI because
a blog said 05 means authenticated is worse than storing nothing: it is a
false record.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Order confirmed, bank never authenticated | Return URL trusted a query parameter | Fetch the payment, or wait for the signed webhook |
| Customer charged twice | Return URL and webhook both captured | One apply function, idempotent capture |
| Challenge abandoned, order stuck “pending” forever | No timeout | Expire C and D after a published window and do not capture |
A treated as success |
Boolean collapsed the statuses | Store the raw status; default A to no capture |
| Works without 3DS in the sandbox | Test card bypasses the challenge | A fixture for C, N, and U, not only Y |
Production checklist
- PAN never posted to your origin
- Raw transaction status persisted
- Return URL re-reads the gateway or defers to the webhook
- One idempotent apply path for both deliveries
- No capture on
C,D,N,R, or by default onAandU - A timeout for challenges that never complete
- ECI stored only when the gateway sent one
Sources
- EMV 3-D Secure — the protocol the status values belong to. Checked 26 September 2026.
- Stripe: 3D Secure — how a gateway surfaces authentication in front of authorisation, including that a required action is not a completed payment. Checked 26 September 2026.
Scheme rules for when liability shifts are not restated here. They differ by network and by region, and a summary of them would go stale faster than the status table.