List Customers API Lookup Lets Anyone Overwrite a Customer
This article may contain affiliate links. Its content is not affected by advertising.
In short
An unauthenticated public form that matches Stripe customers by email alone lets anyone who knows that address impersonate the customer and overwrite the contract metadata on file.
The short version
When an unauthenticated public form matches Stripe customers by email alone, a third party who knows that email can impersonate the real customer and rewrite an existing paid customer’s contract terms through Stripe’s metadata. The fix is to never finalize a customerId found purely by an email match. Only a customerId resolved from a Checkout Session ID — meaning whoever actually paid — is allowed to update immediately. A customer found by email match alone is instead stashed under a different metadata key as a “change requested”, left for a human to review before anything is applied.
What it looks like
The order API (api/order.js) has no dedicated database; both signups and payments are consolidated into Stripe customer records (carrying metadata). When a Checkout Session ID (stripeSessionId) is submitted, the customer who actually paid can be read straight off that Checkout Session. The problem was the branch that runs when there is no session ID.
// api/order.js:77-87 (before the fix)
// If it can't be resolved from a session, look up an existing customer by email (dedup)
if (!customerId) {
const listRes = await fetch(
`https://api.stripe.com/v1/customers?email=${encodeURIComponent(email)}&limit=1`,
{ headers: auth },
);
if (listRes.ok) {
const list = await listRes.json();
if (list.data && list.data[0]) customerId = list.data[0].id;
}
}
This branch was originally written to prevent the same person’s free trial and paid signup from splitting into two separate Stripe customer records. But whatever customerId this branch settled on then flowed straight into the code that follows, unchanged.
const params = new URLSearchParams({
email,
name: company,
"metadata[source]": "formkaeru-lp",
"metadata[status]": "new",
"metadata[plan]": plan,
"metadata[contact_name]": name,
"metadata[offer]": offer,
"metadata[target]": target,
// ...
});
const url = customerId
? `https://api.stripe.com/v1/customers/${customerId}`
: "https://api.stripe.com/v1/customers";
Once customerId is set, the request POSTs metadata straight into that customer, overwriting it. offer (the commercial terms) and target (the delivery target) are free-text values supplied by whoever submitted the form, and this endpoint has nothing beyond a honeypot for bot filtering — no identity check at all. In other words, a third party who knows an existing customer’s email address only has to submit that same email through the same form: customerId resolves via the email match, and the existing customer’s offer/target metadata gets overwritten with whatever the sender typed.
Cause
This code let two ways of “finding an existing customer”, with very different strength of proof, flow into the same variable (customerId):
- Resolving
customerfrom a Checkout Session ID: this ID is issued by Stripe itself when a Checkout Session completes payment, so holding it is itself proof that you are the person who actually paid - Finding a customer via
List Customersby email: an email address is information a third party can also know, so a match proves nothing about identity
For the dedup purpose this code was written for, the email-match result alone would have been enough. But the found customerId was assigned into the exact same variable as the session-based one and handed to the downstream metadata update as an equally “resolved” customer — so two paths of very different identity-proof strength became a single, indistinguishable value by the time execution reached that point. Combined with a form that has no authentication, this structure opened a path for anyone who merely knows an email address to rewrite an existing contract.
The fix
The ID found by email match is now assigned to a separate variable (existingId) instead of customerId. When customerId is still unresolved (no session ID to read it from) and existingId exists, the code no longer applies the write immediately — it diverts to a “change requested” path instead.
// api/order.js:77-118 (after the fix)
// If it can't be resolved from a session, look up an existing customer by email (dedup)
let existingId = null;
if (!customerId) {
const listRes = await fetch(
`https://api.stripe.com/v1/customers?email=${encodeURIComponent(email)}&limit=1`,
{ headers: auth },
);
if (listRes.ok) {
const list = await listRes.json();
if (list.data && list.data[0]) existingId = list.data[0].id;
}
}
// ⚠ This form can be hit by anyone (the only gate is a honeypot).
// If an existing customer's commercial terms (offer/target) can be overwritten
// by email match alone, a third party who knows that email can swap out
// next month's delivery content for that customer.
// Only allow an immediate overwrite when a Checkout Session ID is present
// — i.e. the sender actually paid. Otherwise, stash it as a "change requested"
// under a separate key for a human to review before it's applied.
if (!customerId && existingId) {
const pending = new URLSearchParams({
"metadata[status]": "change_requested",
"metadata[change_requested_at]": new Date().toISOString(),
"metadata[pending_contact_name]": name,
"metadata[pending_offer]": offer,
"metadata[pending_target]": target,
"metadata[pending_points]": points,
"metadata[pending_website]": website,
});
const pr = await fetch(`https://api.stripe.com/v1/customers/${existingId}`, {
method: "POST",
headers: form,
body: pending.toString(),
});
if (!pr.ok) throw new Error("stripe pending update failed");
res.statusCode = 200;
return res.end(
JSON.stringify({
ok: true,
note: "This email is already registered, so we've recorded it as a change request. Staff will confirm and follow up.",
}),
);
}
After the fix, a customer found by email match alone never has its real offer/target metadata overwritten directly — the request is stashed under separate pending_offer/pending_target keys as a “change requested”. The response also tells the sender it was “recorded”, not applied immediately, and the actual update only happens after a human reviews the pending metadata on Stripe. The path where customerId is resolved from a Checkout Session ID — meaning the sender is the person who actually paid — never enters this branch and is applied immediately as before.
Preventing a repeat
This bug happened because multiple ways of deciding “the same customer ID”, each with a different strength of identity proof, were allowed to converge into one value without that difference being tracked by variable or field name. When writing code that resolves an existing record from an identifier supplied by an unauthenticated endpoint — here, an email address — decide first whether that identifier actually proves identity. A record found through a path that doesn’t prove identity needs to stay in a separate variable from a confirmed one, and needs different handling: a human review in the loop instead of an immediate write.
Frequently asked questions
Q1Why is matching by email alone dangerous?
The form has no authentication, so knowing an email already registered in Stripe is enough to resolve that customer's ID without proving identity. The code then writes straight into that metadata, overwriting an existing paid contract without checking who actually sent the request.
Q2Why is a Checkout Session ID safe to trust instead?
A Stripe Checkout Session ID is only ever handed to the person who completed that payment, so the customer ID read from session.customer is guaranteed to belong to whoever actually paid. An email address is information a third party can also know, so it carries nowhere near the same weight as proof.
Q3How is an existing customer found by email now handled?
The code no longer finalizes that customerId. It stashes the change under a separate metadata key (status=change_requested) and replies that staff will follow up. A human reviews the pending metadata on Stripe before anything is applied.
Environment verified
- Vercel Serverless Functions (Node.js, no package.json — calls the Stripe REST API directly via fetch instead of the Stripe SDK)
- Fix confirmed in a commit dated 2026-09-30 (the actual fix shipped in an earlier deploy)
What this article is based on
- JavaScript file lines 77-87commit e5e536d
- JavaScript file lines 77-118commit 9cd8ba2
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.