SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Jun 26, 2026·Security·SecureStartKit Team

Stripe SCA in Next.js: 4 Ways to Lose Automatic 3D Secure

Stripe Checkout handles SCA and 3D Secure automatically in Next.js. Here are the 4 cases that hand the requires_action state machine back to you.

Summarize with AI

On this page

  • Table of contents
  • Does Stripe Checkout handle SCA and 3D Secure automatically?
  • What is the requires_action state machine you inherit?
  • Loss case 1: charging a saved card off-session
  • Loss case 2: building your own card form instead of hosted Checkout
  • Loss case 3: treating checkout.session.completed as fulfillment
  • Loss case 4: assuming you control SCA exemptions
  • How do you keep SCA automatic in a Next.js integration?
  • Frequently asked questions
  • Does a one-time-payment SaaS need to worry about SCA?
  • Will 3D Secure appear for every customer?
  • Is the shipped webhook handler safe with card-only Checkout?
  • What's the difference between checkout.session.completed and a paid payment_status?
  • Do I have to handle requires_action with hosted Checkout?

On this page

  • Table of contents
  • Does Stripe Checkout handle SCA and 3D Secure automatically?
  • What is the requires_action state machine you inherit?
  • Loss case 1: charging a saved card off-session
  • Loss case 2: building your own card form instead of hosted Checkout
  • Loss case 3: treating checkout.session.completed as fulfillment
  • Loss case 4: assuming you control SCA exemptions
  • How do you keep SCA automatic in a Next.js integration?
  • Frequently asked questions
  • Does a one-time-payment SaaS need to worry about SCA?
  • Will 3D Secure appear for every customer?
  • Is the shipped webhook handler safe with card-only Checkout?
  • What's the difference between checkout.session.completed and a paid payment_status?
  • Do I have to handle requires_action with hosted Checkout?

A Stripe-hosted Checkout page handles Strong Customer Authentication and 3D Secure for you automatically, with no payment-authentication code in your Next.js app. Stripe's own docs call Checkout "a prebuilt, Stripe-hosted checkout flow that automatically handles SCA requirements for you" [1]. So the security work in a Next.js integration isn't implementing 3D Secure. It's knowing the four cases where you give that automatic handling away and inherit the requires_action authentication flow yourself.

The Stripe payments with Server Actions guide covers wiring the integration, and the PCI compliance guide covers the card-data scope that wiring creates. This post covers the third thing the redirect buys you for free: cardholder authentication. It explains what SCA and 3D Secure are, what the requires_action state machine is, and the four ways a Next.js integration ends up owning authentication it didn't have to.

TL;DR:

  • Hosted Checkout authenticates for you. The redirect drives 3D Secure on Stripe's domain, so a card-only Checkout integration needs zero authentication code [1].
  • The real fulfillment gate is payment status, not session completion. checkout.session.completed can fire before the money settles. Check payment_status or the PaymentIntent status before you grant access [4].
  • Four cases hand you the authentication flow: charging a saved card off-session, building your own card form, fulfilling on session completion alone, and assuming you control SCA exemptions.
  • Exemptions are the bank's call, not yours. Low-risk transactions "may be exempt," but "banks can still request that the customer complete authentication" [1]. Your integration has to support the flow regardless.

Table of contents

  • Does Stripe Checkout handle SCA and 3D Secure automatically?
  • What is the requires_action state machine you inherit?
  • Loss case 1: charging a saved card off-session
  • Loss case 2: building your own card form instead of hosted Checkout
  • Loss case 3: treating checkout.session.completed as fulfillment
  • Loss case 4: assuming you control SCA exemptions
  • How do you keep SCA automatic in a Next.js integration?
  • Frequently asked questions

Does Stripe Checkout handle SCA and 3D Secure automatically?

Yes. If your Next.js app redirects to a Stripe-hosted Checkout page, Stripe runs any required authentication on its own domain and your code never touches it. Checkout is "a prebuilt, Stripe-hosted checkout flow that automatically handles SCA requirements for you" [1]. The card field, the 3D Secure challenge, and the retry-on-decline loop all live on Stripe's side of the redirect.

Strong Customer Authentication (SCA) is "a rule in effect as of September 14, 2019, as part of PSD2 regulation in Europe," that "requires changes to how your European customers authenticate online payments" [1]. For card payments, the mechanism is 3D Secure (3DS), which Stripe describes as "an authentication protocol that adds an additional security layer to card transactions" by "verifying that the person making a purchase is the legitimate cardholder" [2]. When 3DS is active, the issuing bank may ask the cardholder to confirm the payment with a password, a one-time code, or biometric verification [2].

The reason this matters for architecture: SecureStartKit's shipped createCheckoutSession Server Action does exactly the thing that keeps authentication on Stripe's side. It resolves the price server-side, then creates a hosted session and redirects:

const session = await getStripe().checkout.sessions.create({
  customer: stripeCustomerId,
  mode: 'payment',
  payment_method_types: ['card'],
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: `${process.env.NEXT_PUBLIC_APP_URL}/purchase/success`,
  cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/#pricing`,
  metadata: { user_id: user.id, product_id: productId },
})

redirect(session.url)

No card field renders on the app domain, so there's nothing to authenticate in the app. The Server Action hands the browser to Stripe, and Stripe owns the authentication step. That default is what the four cases below give away.

What is the requires_action state machine you inherit?

When a payment needs authentication, the PaymentIntent moves to a status of requires_action: "If the payment requires additional actions, such as authenticating with 3D Secure, the PaymentIntent has a status of requires_action" [3]. On hosted Checkout, Stripe drives that step for you on its page. The moment you move card collection or charging off the hosted page, completing requires_action becomes your job.

The PaymentIntents API is the thing that decides authentication is needed in the first place. It "tracks the lifecycle of a customer checkout flow and triggers additional authentication steps when required by regulatory mandates, custom Radar fraud rules, or redirect-based payment methods" [1]. So three different forces, regulation, your own fraud rules, and the payment method itself, can flip a payment into requires_action, and none of them are under your direct control.

Here is who owns the authentication step in each integration shape:

IntegrationWho completes requires_actionAuthentication code you write
Hosted Checkout redirect (the shipped pattern)Stripe, on its pageNone
Custom card form with ElementsYou, in the browserconfirmPayment / next-action handling
Off-session charge of a saved cardNobody is present; the charge can failRe-prompt the customer on-session
Assumed exemption that the bank rejectsWhoever rendered the card fieldDepends on where the field lives

A payment that reaches succeeded is the only safe signal to act on. Stripe is explicit: "A PaymentIntent with a succeeded status means that the corresponding payment flow is complete. The funds are now in your account and you can confidently fulfill the order" [3]. Every loss case below is a way of acting before you reach that state.

Loss case 1: charging a saved card off-session

The first way you inherit authentication is charging a stored card when the customer isn't there. An off-session payment is one that "occurs without the direct involvement of the customer, using previously-collected payment information" [5]. A "buy again" button, a top-up, or a renewal that reuses a saved card all run off-session, and an off-session card has no one present to pass a 3D Secure challenge.

When the bank demands authentication on that charge, Stripe returns the authentication_required decline code: "The card was declined because the transaction requires authentication such as 3D Secure" [5]. The recommended fix is not a retry loop. It's bringing the customer back: "in some cases, such as off-session payments, you might need to request the customer to retry" [5]. Practically, that means catching the decline, emailing the customer a link, and re-running the payment on-session so Stripe can show the challenge.

For a one-time-purchase product, this case doesn't appear, because there's no saved-card re-charge in the flow. It shows up the instant you add saved cards for future billing, and the trap is testing only with cards that don't trigger 3DS. The charge works in development against a non-authenticating test card, then fails in production against a European card whose bank wants authentication on an off-session charge.

Loss case 2: building your own card form instead of hosted Checkout

The second way is collecting card details yourself. The moment a card field renders on your domain through Stripe Elements or a custom PaymentIntent flow, you own the authentication step that hosted Checkout was handling. You have to confirm the payment client-side and handle the requires_action next action when 3D Secure fires, instead of letting Stripe's page do it.

This is also where authentication and PCI scope move together. A custom card form pulls a Next.js app into a heavier PCI questionnaire, which the PCI compliance guide covers in detail. The same architectural choice that widens your card-data scope also hands you the 3D Secure flow. Both costs land on the same decision: rendering the card field yourself instead of redirecting.

The honest tradeoff is real. A custom form can be the right call for a bespoke checkout experience. But for most SaaS, the embedded or custom flow trades a measurable amount of security and compliance work for visual control, and the hosted redirect is the lower-risk default. If you don't have a concrete reason to own the card field, don't, and you keep SCA automatic for free.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

Loss case 3: treating checkout.session.completed as fulfillment

The third way is the most common, and it's a timing bug. checkout.session.completed fires when the Checkout flow finishes, not necessarily when the money arrives. For some payment methods the funds settle hours or days later, so granting access on session completion alone hands you a payment that is still authenticating or still clearing.

Stripe's fulfillment guide says to check the session's payment_status before acting: "Check the payment_status property to determine if it requires fulfillment" [4]. The reason is that not every method is instant: "Some payment methods aren't instant, such as ACH direct debit and other bank transfers. This means, funds won't be immediately available when Checkout completes" [4]. For those, Stripe fires a separate event later: "Delayed payment methods generate a checkout.session.async_payment_succeeded event when payment succeeds later" [4], with a matching checkout.session.async_payment_failed for the failure path [4]. Underneath, the PaymentIntent sits in processing, a state that "can take up to several days to confirm" and where "the payment can't be guaranteed" [3].

SecureStartKit's shipped webhook handler records the purchase the moment a completed session arrives:

case 'checkout.session.completed': {
  const session = event.data.object as Stripe.Checkout.Session
  if (session.mode === 'payment') {
    await admin.from('purchases').upsert({
      id: (session.payment_intent as string) || session.id,
      user_id: session.metadata?.user_id ?? '',
      product_id: session.metadata?.product_id || 'securestartkit_template',
      amount: session.amount_total || 0,
      status: 'completed',
    })
    // then send the delivery email with repo access
  }
}

That is correct today, and for a specific reason: the Checkout session is created with payment_method_types: ['card'], and card payments settle synchronously, so by the time checkout.session.completed arrives the payment_status is already paid. The risk is latent. Add one delayed-notification method to that array, or enable automatic payment methods that surface a bank debit, and the same handler records an unpaid purchase and emails out repo access while the money is still clearing. The fix is to gate fulfillment on payment status, not session arrival, and to also handle checkout.session.async_payment_succeeded. Because that event arrives on the same endpoint, the handler still needs to be idempotent, which the webhook retries guide and the signature verification guide both cover.

Loss case 4: assuming you control SCA exemptions

The fourth way is assuming a payment will skip authentication because it looks low-risk. Some transactions "may be exempt from Europe's Strong Customer Authentication requirements" based on fraud-rate analysis [1], but that decision belongs to the bank and Stripe's fraud engine, not your code. Building a flow that can't show a 3D Secure challenge because you "expected an exemption" means a bank-requested authentication has nowhere to go.

Stripe is direct about this: "banks can still request that the customer complete authentication. Even if you're primarily processing low-risk transactions, update your integration so your customers can complete authentication when requested by the bank" [1]. Exemptions are a reason a challenge might not appear, never a guarantee that it won't. The same applies to custom Radar rules you write yourself: a stricter rule can push a payment into requires_action that would otherwise have passed silently.

The takeaway is the same as the other three cases. You don't get to decide that authentication won't happen. You only get to decide whether your integration can handle it when it does. Hosted Checkout always can, because the challenge surface is built in. Anything you build yourself has to keep that surface intact.

How do you keep SCA automatic in a Next.js integration?

Stay on the hosted redirect, and gate fulfillment on payment status rather than session completion. Those two choices keep authentication on Stripe's side and keep a delayed or still-authenticating payment from being treated as a finished one. Everything else in the integration can change without touching the SCA posture, as long as those two hold.

Concretely, four rules keep SCA automatic:

  • Redirect to hosted Checkout instead of rendering a card field, so Stripe owns the 3D Secure challenge and your PCI scope stays minimal.
  • Fulfill on a confirmed payment, by checking payment_status or waiting for a succeeded PaymentIntent, and by handling checkout.session.async_payment_succeeded for delayed methods [4].
  • Don't charge saved cards off-session unless you have a path to bring the customer back on-session to authenticate when you get an authentication_required decline [5].
  • Treat exemptions as a maybe, not a guarantee, and never ship a flow that can't present a challenge the bank asks for [1].

This is the architecture SecureStartKit ships by default: a hosted Checkout redirect, a webhook keyed on the event, and no card field on the app domain. If you want to audit your own integration against these and the rest of the launch checks, the SaaS security checklist walks the payment surface, and the billing architecture guide covers how the one-time and subscription flows differ underneath. For the full picture of how the template wires payments end to end, the Stripe payments guide and the pricing page lay out what comes built in.

Frequently asked questions

Does a one-time-payment SaaS need to worry about SCA?

If you sell to customers in Europe, yes, because SCA is a PSD2 requirement for European card payments [1]. The good news is that a hosted Checkout redirect satisfies it automatically [1]. You don't write authentication code, but you should still confirm your fulfillment logic doesn't act before the payment is confirmed.

Will 3D Secure appear for every customer?

No. 3D Secure fires when the issuing bank or Stripe's fraud rules require it, which is most common for European cards under SCA [1]. Low-risk transactions may be exempt, but the bank can still request authentication on any payment, so your integration has to be able to show the challenge regardless [1].

Is the shipped webhook handler safe with card-only Checkout?

Yes. Because the session is created with payment_method_types: ['card'], payments settle synchronously, so checkout.session.completed arrives with payment_status already paid. If you add a delayed-notification method like a bank debit, you'd need to gate on payment status and handle checkout.session.async_payment_succeeded before fulfilling [4].

What's the difference between checkout.session.completed and a paid payment_status?

checkout.session.completed means the customer finished the Checkout flow; it does not always mean the money arrived [4]. The payment_status field, and the underlying PaymentIntent status, tell you whether funds are confirmed. For delayed methods the session can complete while the PaymentIntent is still processing [3][4].

Do I have to handle requires_action with hosted Checkout?

No. On a hosted Checkout page, Stripe completes the requires_action authentication step for you on its own domain [1]. You only handle requires_action yourself if you collect card details with a custom Elements flow or charge a saved card off-session [3][5].

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Strong Customer Authentication, Stripe Documentation— docs.stripe.com
  2. 3D Secure authentication, Stripe Documentation— docs.stripe.com
  3. The PaymentIntent lifecycle, Stripe Documentation— docs.stripe.com
  4. Fulfill orders after a Checkout payment, Stripe Documentation— docs.stripe.com
  5. Decline codes, Stripe Documentation— docs.stripe.com

Related Posts

Jun 24, 2026·Security

Stripe PCI Compliance for a Next.js SaaS: SAQ A

Stripe Checkout puts a Next.js SaaS in the lightest PCI scope (SAQ A), but four common mistakes widen it. See what keeps card data off your server.

Jun 22, 2026·Security

Stripe Webhook Retries & Missed Events in Next.js

Stripe retries webhooks for 3 days, then stops. Learn how to ack fast, dedup correctly, and reconcile missed events in Next.js with the Events API.

May 19, 2026·Security

Stripe Webhook Signature in Next.js: 5 Failure Modes [2026]

Stripe webhook signature failing in Next.js? 5 causes: parsed body, JSON re-stringify, timestamp drift, wrong secret, missing idempotency.