Where the trust boundary is
Stripe's documentation lists the key types and says which are safe to expose. The publishable key, starting pk_, is for Stripe.js and front-end code. Secret keys, starting sk_, and restricted keys, starting rk_, are not safe to expose. Stripe says that if an unauthorised party obtains your secret key they can make unauthorised charges, access customer data or disrupt your integration.
Card details go from the browser to Stripe, so your server normally never sees them. What your server does control is three decisions: how much to charge, whether a payment really happened, and what to grant in return. Every one of those must be made on the server from data the customer cannot edit. The browser can ask to buy a plan. It must not tell you the price or tell you it has paid.
Stripe delivers the truth about payments through webhooks. Its documentation says that without verification an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders or granting account access.
The checklist
| Area | What to verify | How to test on your own project | Pass condition |
|---|---|---|---|
| Key placement | Only the publishable key is in browser code. | Build the project and search the output and env files with the commands in Step 1. | No sk_ or rk_ key in any browser file or public variable. |
| Key scope | Server code uses a restricted key with only the permissions it needs. | Open the API keys page in the Stripe Dashboard and read the permissions of the key your server uses. | The key cannot do more than your integration requires. |
| Webhook verification | The handler verifies the Stripe-Signature header using the raw request body. | Send a forged event to your own endpoint with curl, as in Step 2. | The forged event gets an error status and nothing is fulfilled. |
| Server-side prices | The amount or price id is chosen on the server, not taken from the request. | Send your own checkout endpoint a changed amount or an unknown price id in a sandbox. | The server ignores or rejects values it did not define. |
| Fulfilment | Access is granted from a verified webhook event, and doing it twice causes no harm. | In a sandbox, pay, then resend the same event from the Dashboard or CLI. | The customer is fulfilled once, and the repeat is recognised and skipped. |
| Success page | Opening the success URL without paying grants nothing. | Visit your own success URL directly with a made-up session id. | No access, credit or order is created. |
| Customer ownership | Billing portal and subscription routes use the Stripe customer linked to the signed-in user. | Sign in as a second test user and call the route with the first user's customer id. | The server uses its own stored mapping and ignores the supplied id. |
| Sandbox and live | Live keys exist only in production settings and were never committed. | Search the repository and its history for sk_live_, rk_live_ and whsec_. | No hits, or every hit has been rotated. |
Step 1: Find every key
Stripe's best practices say never to put secret API keys in source code and never to embed them in applications, and recommend periodically auditing source code, configuration files and pipelines by searching for sk_live_ and rk_live_. They say that if you find a sensitive key, you should assume it has been exposed and compromised. The commands below run that audit on your own project. Adjust the output folder to your framework.
npm run build
grep -rEl "sk_live_|sk_test_|rk_live_|rk_test_|whsec_" dist .next/static 2>/dev/null
grep -rnE "(NEXT_PUBLIC_|VITE_).*(sk_|rk_|whsec_|SECRET)" .env* 2>/dev/null
grep -rnE "sk_live_|rk_live_|whsec_" --exclude-dir=node_modules --exclude-dir=.git .
git log --all -p -S"sk_live_" | head -50Step 2: Verify webhooks
Stripe signs each event with a Stripe-Signature header, and its libraries verify it using the payload, the header and the endpoint's signing secret, which starts with whsec_. The documentation says verification needs the raw body of the request and that any manipulation of the raw body causes it to fail. Frameworks that parse JSON before your handler runs are the usual reason generated code gives up and skips verification.
The example shows the shape for a Next.js route handler: read the raw text, verify, and only then act. The second command sends a forged event to your own endpoint. It must be rejected. Stripe's libraries also apply a default tolerance of five minutes to the signed timestamp to limit replayed events.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_API_KEY as string)
export async function POST(req: Request) {
const payload = await req.text()
const signature = req.headers.get('stripe-signature') ?? ''
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET as string,
)
} catch {
return new Response('Invalid signature', { status: 400 })
}
if (event.type === 'checkout.session.completed') {
await fulfilCheckout(event.data.object.id)
}
return new Response(null, { status: 200 })
}
curl -i -X POST "https://your-site.example/api/webhooks/stripe" \
-H "Content-Type: application/json" \
-d '{"type":"checkout.session.completed","data":{"object":{"id":"cs_fake"}}}'Step 3: Prices and fulfilment
When your server creates a Checkout Session or a payment, the price must come from your own catalogue or from price ids you defined in Stripe. If generated code reads an amount from the request body, a customer can choose what to pay. Test this in a sandbox by sending your own endpoint a smaller amount and seeing what the session shows.
Stripe's fulfilment guide says webhooks are required: you cannot rely on triggering fulfilment only from your checkout landing page, because customers are not guaranteed to visit it. It also says your fulfilment function might be called multiple times, possibly concurrently, for the same Checkout Session, so it must handle that, retrieve the session from the API, check payment_status and record that fulfilment happened.
The webhook documentation adds two behaviours to design for. Endpoints might occasionally receive the same event more than once, so log the event ids you have processed. And Stripe does not guarantee events arrive in the order they were generated.
Step 4: Going live
Stripe separates sandboxes from live mode, each with its own keys, and objects in one are not accessible from the other. Do all the tests on this page with sandbox keys and test cards. When you switch, the documentation recommends restricted keys for server code, storing keys in your platform's secrets store or environment variables, and says a live secret key you create is shown once. It also recommends access policies on live keys so they only work from your known servers.
Each webhook endpoint has its own signing secret, and the secret differs between sandbox and live, so update it when you switch. Subscribe the endpoint only to the event types your integration uses.
Common mistakes
- Putting the secret key in a browser-visible variable so the front end can create a payment directly.
- Skipping signature verification because the framework had already parsed the body and verification kept failing.
- Granting access on the success page without checking the session with Stripe.
- Accepting an amount, a price id or a customer id from the browser without checking it against server data.
- Fulfilling on every delivery of an event, so a retry grants credits twice.
- Using one unrestricted secret key for every service and script.
- Testing a forged webhook or price change against live mode. Use a sandbox.
Keep it working
Repeat the key audit before each release and the forged-event test after any change to the webhook route. Pass condition: no secret key outside server settings, forged events are rejected, the customer cannot influence the price, the success URL grants nothing by itself, and a resent event does not fulfil twice.
Frequently asked questions
Is the Stripe publishable key safe to expose?
Yes. Stripe's documentation marks the publishable key as safe to expose and says you can include it in front-end code. It cannot perform sensitive operations such as creating charges or reading account data. Secret and restricted keys are not safe to expose.
What happens if my Stripe secret key leaks?
Treat it as compromised and rotate it at once. Stripe says someone with your secret key can make unauthorised charges, access customer data or disrupt your integration, and that you should rotate an exposed key immediately even if you are not sure anyone saw it.
Do I need to verify Stripe webhook signatures?
Yes. Stripe's documentation says that without verification an attacker could send fake events to your endpoint to trigger actions such as fulfilling orders or granting access. Verify with the official library, the raw request body and the endpoint's signing secret.
Can I grant access on the Stripe success page?
Not as the only trigger. Stripe says webhooks are required for fulfilment because customers are not guaranteed to reach your landing page. If you also fulfil on the landing page, retrieve the session from Stripe on the server, check its payment status, and make fulfilment safe to run more than once.
Should I use a restricted API key with Stripe?
Yes, where you can. Stripe recommends restricted keys for most use cases because you can assign only the permissions your integration needs, which limits the damage if the key is exposed.