By the end of this you will have a working card-on-file trial: a Stripe SetupIntent created server-side, confirmed in the browser with the Payment Element, a PaymentMethod attached to a Customer, and a job that charges that card off-session on day 14 and handles the authentication_required decline. You will also have a counter that stops your new signup form from being used as a free card validator, which is the part the Stripe docs leave to you.
What you need:
- Node 18+ and the
stripepackage, plus@stripe/stripe-jsand@stripe/react-stripe-json the client. The examples are React. - A Stripe account with a sandbox, and the Stripe CLI for the verification steps.
- A signup flow that already produces a logged-in user before the card form renders. If your card form is reachable without a session, fix that first.
- Card payment methods enabled in your Dashboard payment method settings.
Why a SetupIntent and not a PaymentIntent
A PaymentIntent moves money. A SetupIntent collects and validates payment credentials for later. Stripe's own summary of the Setup Intents API is one sentence: "It's similar to a payment, but no charge is created."
The usage parameter is the part people miss. It defaults to off_session, which tells the issuer you intend to charge this card when the customer is not present. That pushes any SCA authentication forward into the setup flow, so the day-14 charge can be submitted as a merchant-initiated transaction instead of failing and asking a customer who is asleep to approve a 3DS challenge. Setting usage: 'on_session' avoids friction now and buys you declines later, because, as Stripe puts it, "banks are more likely to reject the off-session payment and require authentication from the customer." For a trial that converts to a subscription, off_session is the correct value and you should set it explicitly rather than relying on the default.
Now the mechanism that matters for the second half of this post. To attach a card to a Customer, Stripe has to know the card is real, so confirming a SetupIntent sends a validation to the issuer. Stripe describes it as a request "for either a $0, $1, or similar authorization to verify that the card is valid," and says the $1 variety is temporary and disappears from the statement. No money moves. The caller gets a clean answer to the question "is this card live".
Step 1: create the SetupIntent
Create the Customer once for the authenticated user, store the ID on your user row, and never create one per page load.
// POST /api/billing/setup-intent
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const customerId = user.stripeCustomerId ?? (
await stripe.customers.create({ email: user.email, metadata: { user_id: user.id } })
).id;
const setupIntent = await stripe.setupIntents.create({
customer: customerId,
automatic_payment_methods: { enabled: true },
usage: "off_session",
metadata: { user_id: user.id },
});
res.json({ clientSecret: setupIntent.client_secret });
When it works you get a seti_ object with status: "requires_payment_method" and a client secret shaped seti_..._secret_.... Send only the client secret to the browser.
Step 2: confirm in the browser
Mount the Payment Element against that client secret and call stripe.confirmSetup. The return_url must be a real page on your site, because redirect-based methods and 3DS challenges come back through it.
import { useStripe, useElements, PaymentElement } from "@stripe/react-stripe-js";
export function SetupForm() {
const stripe = useStripe();
const elements = useElements();
const [message, setMessage] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
if (!stripe || !elements) return;
const { error } = await stripe.confirmSetup({
elements,
confirmParams: { return_url: "https://example.com/trial/started" },
});
if (error) setMessage("We could not save that card. Try another one.");
}
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button disabled={!stripe}>Start trial</button>
{message && <p>{message}</p>}
</form>
);
}
The statuses you will see in practice:
| Status | What happened | What you do |
|---|---|---|
requires_payment_method | Created, or the last confirmation failed | Render the element; on failure show one generic message |
requires_confirmation | Payment method attached, not yet confirmed | Only appears if you confirm server-side |
requires_action | 3DS or a redirect is needed | Let Stripe.js run it; the user returns to return_url |
processing | An async method is still settling | Wait for the webhook, do not poll |
succeeded | Card validated and attached to the Customer | Start the trial |
canceled | You canceled it | Nothing |
Verify it now. Run stripe listen --forward-to localhost:3000/api/webhooks/stripe, submit 4242 4242 4242 4242, and watch setup_intent.succeeded arrive in the CLI output. Then submit 4000 0025 0000 3155 and watch the flow stop at requires_action with a 3DS modal before it succeeds.
Step 3: save the card for later, and trust the webhook
On successful confirmation Stripe attaches the resulting PaymentMethod to the Customer for you. What you still have to do is record it and set it as the default, and you should do that from the webhook rather than from the return_url handler. Users close the tab during the bank redirect. Every one of those has a card attached in Stripe while your database thinks the trial never started.
// POST /api/webhooks/stripe (express.raw({ type: "application/json" }))
const event = stripe.webhooks.constructEvent(
req.body, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET
);
if (event.type === "setup_intent.succeeded") {
const si = event.data.object;
await stripe.customers.update(si.customer, {
invoice_settings: { default_payment_method: si.payment_method },
});
await db.startTrial(si.metadata.user_id, {
paymentMethodId: si.payment_method,
trialEndsAt: addDays(new Date(), 14),
});
}
Make the handler idempotent on event.id. Stripe retries, and a duplicate delivery must not extend the trial by another fourteen days.
Step 4: charge the saved payment method when the trial ends
Run this from a scheduled job over the users whose trial_ends_at has passed, not from a web request. The customer is not there, the call can take seconds, and a request timeout that leaves you unsure whether the charge went through is a worse problem than a slow job.
try {
await stripe.paymentIntents.create({
amount: 2900, // minor units
currency: "usd",
customer: customerId,
payment_method: paymentMethodId,
off_session: true,
confirm: true,
});
} catch (err) {
if (err.code === "authentication_required") {
await sendRecoveryEmail(user, err.raw.payment_intent.client_secret);
} else {
await sendCardDeclinedEmail(user, err.code);
}
}
A failed off-session charge returns HTTP 402 and leaves the PaymentIntent in requires_payment_method. The one decline code that needs its own path is authentication_required: the issuer wants the cardholder present, so the save-and-reuse guide has you email the customer a link back into your app where stripe.confirmPayment runs against that same client secret. Everything else is an ordinary dunning email. This is where usage: "off_session" in step 1 pays for itself, because correctly flagging the setup is what lets Stripe claim the SCA exemptions that avoid most of these.
Verify it with a test clock, or more cheaply by attaching 4000 0025 0000 3155 and running the job: the SetupIntent succeeds, and the off-session charge throws authentication_required.
Step 5: the $0 authorization you just exposed
You now have a public endpoint that accepts a card, tells the issuer to validate it, and returns a different answer for live cards than for dead ones. That is a card validation oracle, and card testers were looking for one before you built it.
Stripe says this plainly in its card testing documentation, which lists card setup ahead of payments as an attack surface: "This is a method preferred by fraudulent actors, because card validation and authorizations during card setup don't typically show up on cardholder statements. This reduces the likelihood of card holders noticing and reporting the fraudulent activity."
The run looks like this. A tester buys a list of a few thousand card numbers, loads your trial page in a headless browser, and drives your own Payment Element with your publishable key. Each iteration is a signup, a SetupIntent, and a confirm. succeeded means live, and the card goes in the resale pile. There is no charge for Radar to decline on amount, no dispute for you to lose, and nothing on the statement to alert the cardholder.
Your form is not the only way in, either, because the publishable key is public by design. From an Ask HN thread by tempaccount3333, months into a card testing run against a subscription product: "They simply get my public key (which I've rotated) and create their own checkout session."
The scale is worth one number. At the peak of the 2022 wave, Stripe Radar was blocking more than 20 million card testing attempts per day across its network.
In your Dashboard it reads the way Stripe's own identification guide describes it: a spike in 402s in the Logs view, and new Customers with nonsensical names and emails and no revenue attached. Add the one symptom specific to setup, a setup_intent.setup_failed rate that goes from nearly zero to most of your traffic.
Which is why per-IP rate limits feel like the fix and mostly are not. They catch the tester who forgot to buy proxies. The ones who did not are rotating residential exits, one card per IP, and your counter never reaches two. Meanwhile the threshold you tightened is now firing on a mobile carrier's CGNAT and on the twelve people behind one office egress. Stripe's own guidance is that "simple firewall rules or filters based on a single heuristic such as IP addresses are usually not sufficient to prevent card testing on their own". We went through the rest of the IP-shaped defenses in velocity checks.
Step 6: the counter that catches it
Count distinct card fingerprints per device, not attempts per IP. A real user setting up a trial presents one card, maybe two if the first is declined. Nobody presents six. The attacker's whole method is the opposite ratio, and unlike an IP address, the browser is the thing the script cannot cheaply throw away between iterations.
Stripe gives you the card side for free. payment_method.card.fingerprint is stable for a given card number in your account, so you can compare cards without ever touching a PAN. Read it from the expanded PaymentMethod on setup_intent.succeeded, and from last_setup_error.payment_method on setup_intent.setup_failed, so failed probes count too.
insert into card_probe (device_id, card_fingerprint, seen_at)
values ($1, $2, now()) on conflict do nothing;
-- before creating the next SetupIntent for that device
select count(distinct card_fingerprint) as n
from card_probe
where device_id = $1 and seen_at > now() - interval '24 hours';
Read it before setupIntents.create, not after. At three distinct cards, log it and let it through. At five, refuse.
Refuse carefully. Return exactly the response you return for any other setup failure, with the same status code, the same body, and no hint about whether the card was good. A script that gets a distinguishable error on block has learned something, and a script that gets a slower response on block has learned something too. One generic message, one code path.
This is the shape Portreeve reads. A trial_start or checkout_attempt call to verdict() carrying the device token from @portreeve/browser and the processor's card fingerprint returns allow, review, or block with reason codes in under 100 ms, and the velocity counters behind it run per device and per card rather than per session. The two block-strength card rules read that device-keyed counter, so without the browser snippet those shapes top out at review, which never blocks the user: the flow proceeds and the event lands in a review queue. The device fingerprinting docs cover the snippet, and handling verdicts covers what to do with each one.
Whether to require a card at all
The standard advice for trial abuse is to require a card up front. It is half right and the half it gets wrong is expensive.
Requiring a card does cut casual multi-accounting, because the second free trial needs a second card and most people have two, not two hundred. What it does not do is stop the person with a list of stolen cards, and it hands that person a validation endpoint. If you require a card, you own the card testing problem on the same day. We wrote up the rest of that argument in free trial abuse.
Require a card when the trial itself costs you real money per user, GPU inference, outbound calls, per-seat vendor fees, and when your conversion path genuinely depends on the card already being on file. Skip it when the trial is cheap to serve and you are collecting a card mostly out of habit. In that case a no-card trial with signup screening on email quality, device, and IP reputation gives you most of the abuse reduction and none of the oracle.
If you do require one, the mitigations that matter in order: session required before the card form, distinct-cards-per-device counter, generic failure responses, and only then IP limits.
For production
Cancel abandoned SetupIntents on a sweep so your Customer list does not fill with objects that never resolved. Rate limit POST /api/billing/setup-intent per authenticated user as well as per IP, since the per-user limit is the one an attacker cannot rotate around without also solving your signup flow. Keep your device identifier out of a plain cookie the script can drop on each iteration. And note that Stripe is explicit that its card testing controls are separate from Radar's protection against fraudulent disputes, so a quiet dispute rate is not evidence that your setup endpoint is unfarmed.
The mistakes we see most: trusting the return_url instead of the webhook; a webhook handler that is not idempotent; charging from a request handler; treating authentication_required as a generic decline and dunning a customer who would have paid; and counting attempts per IP because it is the counter that is easiest to build.
Portreeve's free tier screens 1,000 events a month with no card, which is enough to put a verdict call in front of your SetupIntent creation and see what your trial form is getting. Create an account and point it at your signup flow in test mode first.