[{"data":1,"prerenderedAt":552},["ShallowReactive",2],{"blog-how-to/payment-gateway-security-testing":3},{"id":4,"title":5,"body":6,"category":522,"date":523,"dateModified":523,"description":524,"draft":525,"extension":526,"faq":527,"featured":525,"headerVariant":535,"image":536,"keywords":537,"meta":538,"navigation":539,"ogDescription":540,"ogTitle":541,"path":542,"readTime":543,"schemaOrg":544,"schemaType":545,"seo":546,"sitemap":547,"stem":548,"tags":549,"twitterCard":550,"__hash__":551},"blog/blog/how-to/payment-gateway-security-testing.md","Payment Gateway Security Testing Checklist You Can Run (2026)",{"type":7,"value":8,"toc":512},"minimark",[9,13,16,22,25,30,33,49,121,128,138,149,166,170,173,176,187,190,194,197,200,230,236,239,253,256,260,271,274,317,320,334,338,341,344,348,355,358,410,418,426,430,433,436,477,496,508],[10,11,12],"p",{},"Open any payment security checklist, ours included, and you'll find a line that says \"verify webhook signatures.\" Useful. It also doesn't tell you whether the verification you wrote three months ago is still running, or whether a body-parser upgrade quietly broke it.",[10,14,15],{},"A checklist tells you what should be true. This is how you prove it.",[17,18,19],"tldr",{},[10,20,21],{},"Six tests you can run against your own payment integration in an afternoon. They cover forged webhook signatures, replayed events, duplicate deliveries, secret keys in the client bundle, card data in logs, and the failure paths nobody exercises. Each one either passes or hands you a bug. The most common surprise is a signature check that stopped running after a framework upgrade.",[10,23,24],{},"Everything below assumes Stripe, because that's what most vibe-coded apps ship with. The shape transfers to any gateway that signs its callbacks.",[26,27,29],"h2",{"id":28},"test-1-prove-a-forged-webhook-gets-rejected","Test 1: Prove a forged webhook gets rejected",[10,31,32],{},"This is the one that matters most, and it's the one most likely to be silently broken.",[10,34,35,36,40,41,44,45,48],{},"Stripe signs every event with a ",[37,38,39],"code",{},"Stripe-Signature"," header containing a timestamp (",[37,42,43],{},"t=",") and a signature (",[37,46,47],{},"v1=","). Your handler recomputes that signature with your endpoint secret. If it doesn't match, the request isn't from Stripe.",[50,51,53,56],"step",{"number":52},"1",[10,54,55],{},"Send your endpoint a well-formed payload with a garbage signature:",[57,58,63],"pre",{"className":59,"code":60,"language":61,"meta":62,"style":62},"language-bash shiki shiki-themes github-dark","curl -i -X POST https://your-app.com/api/webhooks/stripe \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Stripe-Signature: t=1756704000,v1=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\" \\\n  -d '{\"id\":\"evt_forged\",\"object\":\"event\",\"type\":\"checkout.session.completed\",\"data\":{\"object\":{\"id\":\"cs_forged\",\"amount_total\":100000,\"payment_status\":\"paid\"}}}'\n","bash","",[37,64,65,91,102,112],{"__ignoreMap":62},[66,67,70,74,78,81,85,88],"span",{"class":68,"line":69},"line",1,[66,71,73],{"class":72},"svObZ","curl",[66,75,77],{"class":76},"sDLfK"," -i",[66,79,80],{"class":76}," -X",[66,82,84],{"class":83},"sU2Wk"," POST",[66,86,87],{"class":83}," https://your-app.com/api/webhooks/stripe",[66,89,90],{"class":76}," \\\n",[66,92,94,97,100],{"class":68,"line":93},2,[66,95,96],{"class":76},"  -H",[66,98,99],{"class":83}," \"Content-Type: application/json\"",[66,101,90],{"class":76},[66,103,105,107,110],{"class":68,"line":104},3,[66,106,96],{"class":76},[66,108,109],{"class":83}," \"Stripe-Signature: t=1756704000,v1=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\"",[66,111,90],{"class":76},[66,113,115,118],{"class":68,"line":114},4,[66,116,117],{"class":76},"  -d",[66,119,120],{"class":83}," '{\"id\":\"evt_forged\",\"object\":\"event\",\"type\":\"checkout.session.completed\",\"data\":{\"object\":{\"id\":\"cs_forged\",\"amount_total\":100000,\"payment_status\":\"paid\"}}}'\n",[10,122,123,124,127],{},"You want a ",[37,125,126],{},"400",". Then check your database.",[129,130,131],"danger-box",{},[10,132,133,137],{},[134,135,136],"strong",{},"If that returned 200, read your logs before you do anything else."," A 200 alone might just be sloppy error handling. A 200 plus a new row in your orders table means anyone who can guess your webhook URL can grant themselves paid access, and the payload above shows how little effort that takes. This is the single highest-severity finding we see in payment integrations, and it is almost always a regression rather than something nobody ever implemented.",[10,139,140,141,144,145,148],{},"The usual cause is not missing code. It's that the signature check needs the ",[134,142,143],{},"raw request body",", and Stripe's docs list framework after framework whose default body parsing mutates it. Adding or removing whitespace, reordering keys, or re-encoding the string all break verification. Someone adds ",[37,146,147],{},"express.json()"," globally, the raw body disappears, and verification either throws on every event or gets wrapped in a try/catch that swallows it.",[150,151,152],"warning-box",{},[10,153,154,161,162,165],{},[134,155,156,157,160],{},"Two secrets that both start with ",[37,158,159],{},"whsec_","."," The Stripe CLI prints its own signing secret when you run ",[37,163,164],{},"stripe listen",", and it is not the same as the one in the Dashboard for that endpoint. Verifying CLI-forwarded events with the Dashboard secret (or the reverse) fails every time and sends people hunting for bugs in correct code.",[26,167,169],{"id":168},"test-2-prove-replayed-events-get-rejected","Test 2: Prove replayed events get rejected",[10,171,172],{},"A replay attack is someone capturing a genuine signed request and sending it again. Stripe's defence is the timestamp inside the signed payload: an attacker can't edit it without invalidating the signature, so your handler can reject anything too old.",[10,174,175],{},"The default tolerance in Stripe's libraries is 5 minutes.",[10,177,178,179,182,183,186],{},"Here's the trap, straight from Stripe's docs: ",[134,180,181],{},"don't set the tolerance to 0."," It reads like the strictest possible setting. It isn't. A tolerance of ",[37,184,185],{},"0"," disables the recency check entirely, which turns off exactly the protection you thought you were tightening.",[10,188,189],{},"If you want a shorter window, use a small positive number and make sure your server clock is synced over NTP. Clock drift on the receiving box produces verification failures that look like attacks.",[26,191,193],{"id":192},"test-3-prove-duplicate-deliveries-dont-double-charge","Test 3: Prove duplicate deliveries don't double-charge",[10,195,196],{},"Stripe retries events your endpoint failed to acknowledge. Your handler will see the same event more than once, in production, guaranteed. If granting access or shipping an order isn't idempotent, retries become duplicate fulfilment.",[10,198,199],{},"You don't need to fake this. Redeliver a real event:",[50,201,202,205,227],{"number":52},[10,203,204],{},"Redeliver an event you've already processed:",[57,206,208],{"className":59,"code":207,"language":61,"meta":62,"style":62},"stripe events resend evt_1ABCxyz --webhook-endpoint=we_1DEFuvw\n",[37,209,210],{"__ignoreMap":62},[66,211,212,215,218,221,224],{"class":68,"line":69},[66,213,214],{"class":72},"stripe",[66,216,217],{"class":83}," events",[66,219,220],{"class":83}," resend",[66,222,223],{"class":83}," evt_1ABCxyz",[66,225,226],{"class":76}," --webhook-endpoint=we_1DEFuvw\n",[10,228,229],{},"This works for up to 30 days after the event was created.",[50,231,233],{"number":232},"2",[10,234,235],{},"Check that the side effect happened exactly once. One order row. One access grant. One receipt email.",[10,237,238],{},"Two details decide whether this passes.",[10,240,241,242,245,246,249,250,252],{},"Dedupe on the ",[134,243,244],{},"event ID",", not the timestamp. Stripe's docs are explicit that snapshot events record ",[37,247,248],{},"created"," in whole seconds, so genuinely distinct events can share a timestamp. Using ",[37,251,248],{}," to decide \"have I seen this?\" will eventually drop a real event.",[10,254,255],{},"And don't dedupe on the signature: when Stripe retries a delivery, it generates a new signature and a new timestamp for that attempt. Same event ID, different signature.",[26,257,259],{"id":258},"test-4-prove-your-secret-key-isnt-in-the-client-bundle","Test 4: Prove your secret key isn't in the client bundle",[10,261,262,263,266,267,270],{},"Publishable keys belong in the browser. Secret keys (",[37,264,265],{},"sk_live_",", ",[37,268,269],{},"sk_test_",") never do. The failure mode is a build tool inlining an env var that wasn't meant to be public.",[10,272,273],{},"Don't grep your source. Grep what you actually shipped:",[57,275,277],{"className":59,"code":276,"language":61,"meta":62,"style":62},"npm run build\ngrep -rn \"sk_live_\\|sk_test_\\|whsec_\" dist/ .next/ build/ 2>/dev/null\n",[37,278,279,290],{"__ignoreMap":62},[66,280,281,284,287],{"class":68,"line":69},[66,282,283],{"class":72},"npm",[66,285,286],{"class":83}," run",[66,288,289],{"class":83}," build\n",[66,291,292,295,298,301,304,307,310,314],{"class":68,"line":93},[66,293,294],{"class":72},"grep",[66,296,297],{"class":76}," -rn",[66,299,300],{"class":83}," \"sk_live_\\|sk_test_\\|whsec_\"",[66,302,303],{"class":83}," dist/",[66,305,306],{"class":83}," .next/",[66,308,309],{"class":83}," build/",[66,311,313],{"class":312},"snl16"," 2>",[66,315,316],{"class":83},"/dev/null\n",[10,318,319],{},"Empty output is a pass. A hit is an incident: rotate the key in the Stripe Dashboard first, then fix the build, because the key is already in every browser cache that loaded your site.",[10,321,322,323,266,326,329,330,333],{},"This catches the framework-prefix mistake too. A secret assigned to a ",[37,324,325],{},"NEXT_PUBLIC_",[37,327,328],{},"VITE_",", or ",[37,331,332],{},"PUBLIC_"," variable gets inlined into client JavaScript by design, and nothing warns you.",[26,335,337],{"id":336},"test-5-prove-card-data-isnt-in-your-logs","Test 5: Prove card data isn't in your logs",[10,339,340],{},"If you use Stripe Elements, Checkout, or Payment Links, raw card numbers never touch your server, which is the entire point of using them. What does reach your logs is everything around the payment: full request bodies, webhook payloads, error objects with customer details.",[10,342,343],{},"Run a real test payment, then search your logs for the card number you used and for the customer's email. Then check wherever your errors land, because an exception handler that dumps the whole request object is a different code path from your access logger and is usually the one nobody reviewed.",[26,345,347],{"id":346},"test-6-run-the-failure-paths","Test 6: Run the failure paths",[10,349,350,351,354],{},"Almost every integration gets tested with ",[37,352,353],{},"4242 4242 4242 4242"," and nothing else. That card always succeeds, so the entire error-handling branch of your checkout ships unexecuted.",[10,356,357],{},"Work through these in a sandbox with test keys:",[359,360,361,374],"table",{},[362,363,364],"thead",{},[365,366,367,371],"tr",{},[368,369,370],"th",{},"Card",[368,372,373],{},"What it simulates",[375,376,377,386,394,402],"tbody",{},[365,378,379,383],{},[380,381,382],"td",{},"4000 0000 0000 0002",[380,384,385],{},"Generic decline",[365,387,388,391],{},[380,389,390],{},"4000 0000 0000 9995",[380,392,393],{},"Insufficient funds",[365,395,396,399],{},[380,397,398],{},"4000 0000 0000 9979",[380,400,401],{},"Stolen card",[365,403,404,407],{},[380,405,406],{},"4000 0000 0000 0259",[380,408,409],{},"Succeeds, then disputed as fraudulent",[10,411,412,413,417],{},"The security-relevant question isn't whether the decline message renders. It's what your app does ",[414,415,416],"em",{},"around"," the decline. Does a failed payment leave a half-created account with access already granted? Does the retry path let someone hammer checkout to brute-force card numbers? Does the fraudulent-dispute card leave you with an order marked fulfilled?",[150,419,420],{},[10,421,422,425],{},[134,423,424],{},"Never test in live mode with a real card."," The Stripe Services Agreement prohibits testing in live mode using real payment method details. Use test API keys and a sandbox, where transactions don't move funds.",[26,427,429],{"id":428},"what-passing-actually-means","What passing actually means",[10,431,432],{},"These six tests prove your controls run. They don't prove your business logic is right, and they say nothing about whether the account that receives the money is locked down.",[10,434,435],{},"Run them after every dependency upgrade that touches your HTTP layer. Test 1 exists because signature verification is the control most likely to break without anyone noticing, and a body-parser change is enough to do it.",[437,438,439,449,455,461,471],"faq-section",{},[440,441,443],"faq-item",{"question":442},"How do I test that my webhook signature verification actually works?",[10,444,445,446,448],{},"Send your endpoint a request with a syntactically valid but wrong ",[37,447,39],{}," header using curl. A correct handler returns 400 and does not process the event. If it returns 200, or if your database changed, verification is not running on that path.",[440,450,452],{"question":451},"What tolerance should I set for Stripe webhook timestamps?",[10,453,454],{},"Leave it at the default of 5 minutes. Stripe's docs explicitly warn against setting a tolerance of 0, because a tolerance of 0 disables the recency check entirely rather than making it stricter. If you want tighter replay protection, use a small positive value like 60 seconds and keep your server clock on NTP.",[440,456,458],{"question":457},"Can I test payments with a real credit card in live mode?",[10,459,460],{},"No. The Stripe Services Agreement prohibits testing in live mode using real payment method details. Use test API keys and Stripe's test card numbers in a sandbox, where transactions do not move funds.",[440,462,464],{"question":463},"How do I test that duplicate webhook deliveries do not double-charge?",[10,465,466,467,470],{},"Use ",[37,468,469],{},"stripe events resend \u003Cevent_id> --webhook-endpoint=\u003Cendpoint_id>"," to redeliver a real event you already processed. It works for up to 30 days after the event was created. Then check that your side effect happened exactly once. Dedupe on the event ID, not on the timestamp.",[440,472,474],{"question":473},"What test card numbers should I use for failure paths?",[10,475,476],{},"4000 0000 0000 0002 for a generic decline, 4000 0000 0000 9995 for insufficient funds, 4000 0000 0000 9979 for a stolen card, and 4000 0000 0000 0259 for a charge that succeeds and is then disputed as fraudulent. Testing only 4242 4242 4242 4242 means you have never run your own error handling.",[478,479,480,486,491],"related-articles",{},[481,482],"related-card",{"description":483,"href":484,"title":485},"The 15 items to verify before going live","/blog/checklists/payment-integration-checklist","Payment Integration Checklist",[481,487],{"description":488,"href":489,"title":490},"How to implement signature verification","/blog/blueprints/stripe-webhooks","Stripe Webhooks Security",[481,492],{"description":493,"href":494,"title":495},"Find secrets that reached your client bundle","/blog/how-to/check-exposed-keys","Check for Exposed Keys",[497,498,501,505],"cta-box",{"href":499,"label":500},"/","Start Free Scan",[26,502,504],{"id":503},"taking-payments","Taking payments?",[10,506,507],{},"Scan your app for exposed keys and misconfigured endpoints before your next release.",[509,510,511],"style",{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"title":62,"searchDepth":93,"depth":93,"links":513},[514,515,516,517,518,519,520,521],{"id":28,"depth":93,"text":29},{"id":168,"depth":93,"text":169},{"id":192,"depth":93,"text":193},{"id":258,"depth":93,"text":259},{"id":336,"depth":93,"text":337},{"id":346,"depth":93,"text":347},{"id":428,"depth":93,"text":429},{"id":503,"depth":93,"text":504},"how-to","2026-09-01","Every payment checklist says verify webhook signatures. None say how to prove it works. Six tests you can run against your own Stripe integration today.",false,"md",[528,530,531,532,534],{"question":442,"answer":529},"Send your endpoint a request with a syntactically valid but wrong Stripe-Signature header using curl. A correct handler returns 400 and does not process the event. If it returns 200, or if your database changed, verification is not running on that path.",{"question":451,"answer":454},{"question":457,"answer":460},{"question":463,"answer":533},"Use stripe events resend \u003Cevent_id> --webhook-endpoint=\u003Cendpoint_id> to redeliver a real event you already processed. It works for up to 30 days after the event was created. Then check that your side effect happened exactly once. Dedupe on the event ID, not on the timestamp.",{"question":473,"answer":476},"yellow",null,"payment gateway security testing checklist, stripe webhook testing, test webhook signature verification, payment integration testing, stripe test cards",{},true,"Six runnable tests that prove your payment integration rejects forged webhooks, survives replays, and keeps keys off the client.","Payment Gateway Security Testing Checklist","/blog/how-to/payment-gateway-security-testing","9 min read","[object Object]","HowTo",{"title":5,"description":524},{"loc":542},"blog/how-to/payment-gateway-security-testing",[],"summary_large_image","H_BGZduuql4ms3abaTFay3-PdG0DZ7KEO6E4uCQS6bs",1789672859740]