Unsigned webhooks and request bodies written to the database
Some of the most damaging authorization bugs do not look like authorization code at all. They look like plumbing. A webhook route that receives an event and updates an order. An update handler that takes the request body and saves it. Neither has an obvious place for a permission check, which is exactly why the check is often missing.
Vulkro 0.30.0 added a set of authorization checks to its scan, and two of them cover this plumbing: webhooks that write data without verifying who sent the request, and request bodies written straight to the database. This post walks through both, with the vulnerable shape, the fix and what the scanner reports.
Part one: the webhook anyone can call
A webhook is a public URL that a payment provider, a code host or a shipping service calls when something happens. It has to be reachable from the internet, it cannot use your normal sign-in, and it usually does something with consequences: marks an invoice paid, grants a subscription, ships an order, merges a deploy.
Stripe's documentation states the risk directly: without verification, an attacker could send fake events to "trigger actions like fulfilling orders, granting account access or modifying records." The defence every serious provider offers is a signature. The provider computes an HMAC over the request body with a secret only you and the provider know, and sends it in a header. Your handler recomputes it and rejects anything that does not match.
The shape we see most
Teams usually get the first webhook right, because it was the payment one and someone followed the provider's guide. The second one, added months later for a smaller integration, is where the check goes missing:
// webhooks.js
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET,
);
// ... handle event
res.sendStatus(200);
});
// Vulnerable: no signature check, but it writes to the database
app.post('/webhooks/shipping', express.json(), async (req, res) => {
await db.order.update({
where: { id: req.body.orderId },
data: { status: req.body.status },
});
res.sendStatus(200);
});
Anyone who learns the URL can mark any order as delivered, refunded or cancelled.
Verifying a signature properly
Here is the shipping webhook with a signature check modelled on what the major providers document. It does four things, and each one matters:
const crypto = require('crypto');
app.post('/webhooks/shipping', express.raw({ type: 'application/json' }), async (req, res) => {
const header = req.get('x-shipping-signature') || ''; // "t=1712345678,v1=abc..."
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const timestamp = Number(parts.t);
// 1. Reject old requests (replay protection)
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);
// 2. HMAC over the timestamp and the RAW body bytes
const expected = crypto
.createHmac('sha256', process.env.SHIPPING_WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body) // a Buffer, not parsed JSON
.digest('hex');
// 3. Constant-time comparison
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || '');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(400);
// 4. Only now parse and act
const event = JSON.parse(req.body.toString('utf8'));
await db.order.update({ where: { id: event.orderId }, data: { status: event.status } });
res.sendStatus(200);
});
Raw body. The HMAC is computed over the exact bytes the sender signed. If a JSON middleware has already parsed and re-serialised the body, whitespace and key order can change and the digest no longer matches. Stripe's webhook guide warns that any manipulation of the raw body causes verification to fail, and its troubleshooting guide notes that express.json() must not run before the webhook route.
Constant-time comparison. A plain == on strings can return as soon as the first byte differs, which leaks timing information. GitHub's guide to validating webhook deliveries says to use a constant-time comparison such as Node's crypto.timingSafeEqual, and to never use a plain == operator. GitHub sends the HMAC SHA-256 hex digest in X-Hub-Signature-256, prefixed with sha256=.
Timestamps. A valid signed request can be captured and replayed. Stripe signs a timestamp along with the body, so it cannot be changed without breaking the signature, and its libraries reject events more than five minutes old by default. Stripe also warns against a tolerance of 0, which disables the recency check entirely.
Duplicates. Providers deliver at least once and retry on failure, so record processed event ids and skip ones you have already handled. That protects you from replays inside the tolerance window too.
When the provider ships a verification function (stripe.webhooks.constructEvent, for example), use it rather than writing your own.
Part two: the request body written straight to the database
The second gap is mass assignment: a handler that takes the whole request body and writes it to a record.
// Vulnerable: every field in the body is written
app.patch('/api/users/:id', requireAuth, requireSelf, async (req, res) => {
const user = await prisma.user.update({
where: { id: req.params.id },
data: req.body,
});
res.json(user);
});
The client is supposed to send {"displayName": "Ana"}. Nothing stops it sending {"role": "admin", "emailVerified": true}, and if those columns exist, they are written. The route checks that the caller is signed in and editing their own account. It never checks which fields they may change.
The fix is an allowlist, ideally a schema that rejects anything else:
const UpdateProfile = z.object({
displayName: z.string().max(80).optional(),
avatarUrl: z.string().url().optional(),
}).strict();
app.patch('/api/users/:id', requireAuth, requireSelf, async (req, res) => {
const data = UpdateProfile.parse(req.body);
const user = await prisma.user.update({ where: { id: req.params.id }, data });
res.json(user);
});
The same bug in Django is a dictionary unpacked into the ORM:
# Vulnerable
def update_profile(request):
Profile.objects.filter(user=request.user).update(**request.POST.dict())
# Fixed: a form or serializer with an explicit field list
class ProfileForm(forms.ModelForm):
class Meta:
model = Profile
fields = ["display_name", "bio"]
And in Go with gin, binding the body onto the stored model rather than onto a request struct:
// Vulnerable: binds every JSON field onto the stored user
var user models.User
db.First(&user, id)
c.BindJSON(&user)
db.Save(&user)
// Fixed: bind a request struct, copy the allowed fields
var in struct{ DisplayName string `json:"displayName"` }
if err := c.ShouldBindJSON(&in); err != nil {
c.AbortWithStatus(http.StatusBadRequest)
return
}
db.Model(&user).Update("display_name", in.DisplayName)
OWASP files this under API3:2023 Broken Object Property Level Authorization, which merged the 2019 categories Mass Assignment and Excessive Data Exposure. MITRE's entry is CWE-915, improperly controlled modification of dynamically-determined object attributes.
The best-known incident is GitHub's. On 4 March 2012 a user exploited the public key update form to add his own key to the rails organisation and pushed a file to the project as a demonstration. GitHub's write-up attributed the root cause to "a failure to properly check incoming form parameters," removed the key, suspended the account and audited its codebase for the same pattern. Rails 4 later made strong parameters, an explicit allowlist per action, the default.
What Vulkro reports
Webhooks. webhook-unsigned-sibling (High) fires when a webhook route writes to the data store without verifying a request signature while another webhook in the same file does verify one, the first example above. Because the rule needs a verifying sibling to exist, it rests on something the scanner can see: the team already knows how to verify, and one route does not. A separate, pattern-based check looks for handlers of known providers (Stripe, GitHub, Shopify, Slack and others) that read the body before any signature check, and non-constant-time-compare flags secrets compared with a plain equality operator. Where a missing-authentication finding's endpoint writes a table holding credentials, secrets, financial or health data, the finding says so.
Request bodies. Several rules cover the body-to-record shape by framework: js-request-body-into-write and nest-loose-body-merged-into-entity (High) for JavaScript and TypeScript, pydantic-extra-allow-mass-assignment (High) for a Pydantic model that accepts extra fields and is written onto a stored object, go-bind-onto-stored-model (High) for Go, plus Java and PHP rules for model binding and Eloquent. They require the ORM write and the unfiltered body in the same handler, and stay quiet when the handler destructures specific fields or, in Python, takes a Pydantic-typed input. When the schema map shows that the written table has privileged columns (role, admin flag, balance, owner, tenant), the finding names them. A role written from caller input with no role check is its own rule, authz-role-change-no-role-check.
Each finding names the route and the write, and vulkro prove shows the data-flow hops behind it where they can be established.
Where to see it
Run vulkro scan on your API and look for these rule ids, or run vulkro explain <rule> for the full description and fix. The API3 rule page covers mass assignment and the API2 page covers missing authentication. To get set up, start here.
Sources
- Stripe, Receive Stripe events in your webhook endpoint
- Stripe, Manage webhook endpoints
- GitHub Docs, Validating webhook deliveries
- OWASP, API3:2023 Broken Object Property Level Authorization
- MITRE, CWE-915
- GitHub, Public key security vulnerability and mitigation, 4 March 2012
