Webhook security for in-app purchases: a shared secret

Webhook security for a subscription app: a shared secret compared in constant time, a rollout with no rejected events, gated sandbox buys, re-checked grants.

By

Your purchase webhook decides who has paid. If it trusts whatever body arrives, anyone who finds the URL can mark any account as paid. Webhook security for in-app purchases comes down to a few checks: prove the request came from your provider, roll that proof out without dropping real events, keep test purchases out of production, and stop trusting a stored "paid" forever. These are the fixes I made on a subscription app I work on, which uses RevenueCat, and the order I would do them in on any app.

How an open purchase webhook marks any account paid

Webhooks are public by design. The provider's servers cannot log in as a user, so the webhook route usually sits before the authentication middleware. That is fine, as long as the route has its own way of checking the caller.

On this app it had none. The purchase webhook endpoint sat before the login check and trusted any body. Anyone who knew the URL could post a made-up initial-purchase event with any user's ID and mark that account paid. Even an unadvertised URL is a weak secret: it shows up in logs, in config screens and in the heads of everyone who has worked on the project.

This is fixed. The rest of this post is what the fix looks like.

Check a shared secret on every webhook request

RevenueCat lets you set an authorization header value that it sends with every webhook POST, and its webhook documentation says your server should verify that header on every notification. So the endpoint now answers 401 unless the Authorization header matches a shared secret.

The comparison is done in constant time with Node's crypto.timingSafeEqual:

import { timingSafeEqual } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';

function secretMatches(header: string | undefined, secret: string): boolean {
  if (!header) return false;
  const given = Buffer.from(header);
  const expected = Buffer.from(secret);
  // timingSafeEqual throws when the lengths differ
  return given.length === expected.length && timingSafeEqual(given, expected);
}

export function requireWebhookSecret(req: Request, res: Response, next: NextFunction) {
  const secret = process.env.PURCHASE_WEBHOOK_SECRET;
  // Unset only during rollout, see below
  if (secret && !secretMatches(req.get('authorization'), secret)) {
    res.sendStatus(401);
    return;
  }
  next();
}

The header value is whatever you type into the provider's dashboard, so compare the whole header against the whole secret. If you configure Bearer abc123 there, the secret on the server is Bearer abc123.

Why constant time, and why the length check

A plain === on strings can return as soon as it finds the first character that differs. In principle, an attacker who can measure response times precisely enough can use that to guess a secret one character at a time. A constant-time compare looks at every byte regardless, so the timing tells them nothing.

timingSafeEqual throws if the two buffers have different lengths, which is why the length check comes first. Without it, a request with a short header would crash the handler with a 500 instead of getting a clean 401. The length check does reveal whether the length matches, which is acceptable: the length of a long random secret is not the part an attacker needs.

Generate the secret like a password: long and random, stored in your secrets manager, never committed.

Roll out the secret without rejecting real events

There is a chicken-and-egg problem here. If the server starts demanding the header before the provider sends it, every real purchase event in between gets a 401. If the provider sends the header before the server checks it, nothing breaks.

So the order is:

  1. Deploy the code with the secret unset. In that state the server accepts requests as before (the if (secret && ...) branch above).
  2. Configure the secret in the provider's dashboard. Real events now arrive with the header, and the server still accepts them.
  3. Set the secret on the server. From this moment the check is enforced, and every real event already carries the right header.

That sequence leaves no window where real events get rejected. RevenueCat does retry failed deliveries a few times with backoff, but I would rather not rely on retries to cover a mistake in my own rollout.

One caution about the "unset means open" behaviour: it is a rollout convenience. Once the secret is live, I want an alert or a startup warning if a production server ever boots without it.

Keep sandbox purchases out of production entitlements

The second hole was quieter. Test-store (sandbox) purchases grant the same real entitlements as paid ones. That is useful while you are testing and dangerous in production, because a sandbox purchase costs nothing. If a test key or a test build ever reached the wrong people, sandbox events would switch on paid features on the live service. That is exactly the scenario I fixed on the client side in keeping test API keys out of production builds.

Now the live check counts sandbox purchases only where an explicit environment flag allows it, which is staging and local. Production ignores them unless someone deliberately turns that flag on. RevenueCat marks sandbox events with an environment value of SANDBOX, and its dashboard can also filter which environments a webhook receives, so you can add the same rule at the provider.

I like the flag to be explicit and default to off. A missing variable should produce the safe behaviour.

Re-check paid statuses that look permanent

The third problem was in old data. Old manual grants had been stored as the purchase date plus 100 years. The app trusted a stored "paid" status until its expiry date. So users whose grant had already expired with the provider kept paid features, because the local row still said they had access until roughly the next century.

The fix keeps the fast path and adds a check for suspicious rows. A paid status with no expiry, or with an expiry more than 10 years out, is re-checked with the provider. The answer is cached for 5 minutes so a user who opens the app repeatedly does not trigger a provider call each time. Real lifetime grants come back from the provider as active and stay paid.

The general rule I took from it: a local copy of subscription state is a cache. Anything that looks like it will never expire deserves a second look at the source. If you run a SaaS with plan-based access, the same idea shows up in gating access across tenants in a multi-tenant SaaS.

Is a shared secret enough for webhook security?

For most apps, a long random shared secret over HTTPS, compared in constant time, is a solid baseline. RevenueCat also offers HMAC signing of each delivery with a timestamp, which proves the body was not altered and lets you reject replays, so I would turn that on where the provider supports it. Whichever you use, make handlers idempotent, because providers retry and the same event can arrive twice. Stripe's engineering blog explains the reasoning well in designing APIs with idempotency.

The short version

  • Treat the webhook URL as public and authenticate every request with a shared secret, compared with timingSafeEqual after a length check.
  • Configure the secret at the provider first and on the server second.
  • Count sandbox purchases only where an explicit flag allows it.
  • Re-check any stored "paid" that never expires.

Webhooks like this are easy to forget once they work, so I put them on the list whenever I review a backend, alongside removing API endpoints nothing uses. If you want a second pair of eyes on yours, that is part of the services I offer.

Want this kind of work on your product?

Get in touch