Skip to content
Back to skills

Webhook Signature Verification

ASecurity

Accept inbound webhooks (Stripe, GitHub, Slack, partner) and verify HMAC signatures in Apex REST — Crypto.verifyHMac platform verification, secret in Protected Custom Metadata, replay-window rejection. NOT for designing the receiver end to end — Sites routing, guest-user access, idempotency, the 5-second response window — use integration/webhook-inbound-patterns. NOT for signing a webhook Salesforce sends out — use integration/outbound-webhook-from-salesforce.

  • 15 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 6, 2026
ai-agentsgospringgitdatabasesecuritydocumentation

Works with

  • cli

Security analysis

A100/100

Pro scans all 7 files and shows the line behind each finding

Scanned September 6, 2026

npx -y skills add PranavNagrecha/AwesomeSalesforceSkills --skill webhook-signature-verification --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Webhook Signature Verification?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Webhook Signature Verification
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/pranavnagrecha-webhook-signature-verification/badge)](https://www.skillsdirectory.com/skills/pranavnagrecha-webhook-signature-verification)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: webhook-signature-verification
description: "Accept inbound webhooks (Stripe, GitHub, Slack, partner) and verify HMAC signatures in Apex REST — Crypto.verifyHMac platform verification, secret in Protected Custom Metadata, replay-window rejection. NOT for designing the receiver end to end — Sites routing, guest-user access, idempotency, the 5-second response window — use integration/webhook-inbound-patterns. NOT for signing a webhook Salesforce sends out — use integration/outbound-webhook-from-salesforce."
category: integration
salesforce-version: "Spring '25+"
well-architected-pillars:
  - Security
  - Reliability
triggers:
  - "webhook hmac verification apex"
  - "stripe webhook salesforce"
  - "verify signature inbound callout"
  - "hmac sha256 apex"
tags:
  - webhook
  - hmac
  - signature
  - apex-rest
inputs:
  - "webhook provider HMAC spec"
  - "shared secret location"
outputs:
  - "@RestResource endpoint with signature verification"
dependencies: []
version: 1.0.1
author: Pranav Nagrecha
updated: 2026-08-14
---


# Webhook Signature Verification

An inbound webhook is an HTTP POST from a third party that Salesforce did not
initiate and cannot authenticate by session. When the endpoint is exposed through
a public Salesforce Site, the signature check is the *entire* authentication
boundary — there is no user, no token, and no network control in front of it.

Three properties have to hold, and each fails independently:

| Property | Established by | Fails when |
|---|---|---|
| **Authenticity** — the sender holds the shared secret | HMAC over the payload | Secret is wrong, or the wrong encoding of the right secret |
| **Integrity** — the bytes were not altered in transit | HMAC over the **raw** body | The body was parsed and re-serialised before hashing |
| **Freshness** — this is not a replay of an earlier valid request | Timestamp tolerance + idempotency key | Neither is implemented — the signature alone never establishes this |

Most broken webhook endpoints have the first two and not the third.

**Scope.** This skill owns the *verification* step: which Apex crypto method to
call, over which bytes, against which secret, and what to reject. Designing the
receiver end to end — Sites routing, guest-user access model, the idempotency
schema, the response-window budget — belongs to
`integration/webhook-inbound-patterns`. Those subjects appear below only as far as
they change the verification decision (for example: on a public Site the signature
is the only gate, which raises the stakes on every gotcha here). Signing a webhook
Salesforce *sends* is `integration/outbound-webhook-from-salesforce`.

---

## Before Starting

1. **Identify the signature scheme.** HMAC (shared secret) or asymmetric
   (RSA/ECDSA against a published public key)? They need different Apex methods
   and different secret storage. Do not assume HMAC.

2. **Get the exact header name, value format, and signed payload.** Not every
   provider signs the body alone — Stripe signs `"{timestamp}.{body}"`. Getting
   this wrong produces 100% verification failure that looks like a key problem.

3. **Decide how the request reaches Apex.** `/services/apexrest/*` requires an
   authenticated session, which a webhook does not have. Either the provider
   obtains a token (Connected App, client credentials flow) or you expose the class
   through a Salesforce Site guest user. Pick before you write the class.

4. **Choose where the secret lives.** Protected Custom Metadata, read via
   `getAll()`. Not a literal, not a Custom Setting, not a Named Credential (which
   is for outbound).

---

## Core Concepts

### `Crypto.verifyHMac` is the method you want

```apex
public static Boolean Crypto.verifyHMac(
    String algorithmName,   // hmacMD5 | hmacSHA1 | hmacSHA256 | hmacSHA512
    Blob   data,
    Blob   privateKey,      // max 4 KB
    Blob   macToVerify
)
```

`Crypto.generateMac` produces a MAC and is correct for *building* a signature (in
tests, or when calling out). It is the wrong tool for checking one, because it
forces you to write the comparison yourself — and `String.equals` short-circuits
on the first differing character. Both Stripe and GitHub explicitly tell
integrators to compare signatures in constant time.

**What the Apex Reference Guide does and does not say.** Its `verifyHMac` entry
documents the signature, the four valid algorithm names, the 4 KB key cap, the
Base64 symmetry rule, and the Boolean return. It makes **no timing claim**. So
`verifyHMac` is the recommended method here because it keeps the comparison out
of your code entirely, not because Salesforce documents it as constant-time —
that property is undocumented, and this skill does not assert it. Detail in
[`references/gotchas.md`](references/gotchas.md), Gotcha 4.

For asymmetric providers:

```apex
public static Boolean Crypto.verify(String algorithmName, Blob data,
                                    Blob signature, String certDevName)
```

with the provider's public certificate stored in **Setup → Certificate and Key
Management**, so rotation is a Setup change rather than a deploy.

### The Base64 symmetry rule

> "You may supply a private key that has been encoded using Base64 encoding.
> However if you do, then you must also supply the Base64-encoded private key when
> verifying the MAC using the `verifyHMac` method."
> — Apex Reference Guide, `Crypto.generateMac`

Record in a comment which form the provider uses. Stripe's `whsec_...` is an
opaque ASCII string used raw; some providers issue a Base64 blob that must be
decoded first.

### Raw body only

`RestRequest.requestBody` is a `Blob` — the exact bytes the sender hashed.
`JSON.serialize(JSON.deserializeUntyped(...))` reorders keys, strips whitespace,
and normalises numbers, so a MAC computed over the round trip never matches. Verify
first; parse second.

### Headers are case-insensitive; Apex maps are not

`RestRequest.headers` is a `Map<String, String>` with exact-match lookup. Proxies
and provider clients normalise header casing differently, so
`headers.get('Stripe-Signature')` can return `null` in production while working in
a test that used the same spelling. Always look up case-insensitively.

### Size ceiling

"The maximum request or response size is 6 MB for synchronous Apex or 12 MB for
asynchronous Apex." A provider posting near that ceiling has outgrown the webhook
shape; move to notification-plus-pull.

---

## Common Patterns

### Pattern A — verify, stage, return 200, process async

The default. The resource verifies, upserts the raw event against an External Id,
enqueues a Queueable, and returns. Providers time out in single-digit seconds and
retry on timeout; anything slower turns one event into repeated deliveries. Full
implementation in [`references/examples.md`](references/examples.md), Example 2.

### Pattern B — replay window before HMAC

If the provider signs a timestamp, check it first. A replay flood then costs a
`Long` comparison rather than a crypto operation. Stripe's libraries "have a
default tolerance of 5 minutes between the timestamp and the current time," and
Stripe warns against a tolerance of `0` because it disables the recency check
entirely.

### Pattern C — accept multiple signatures during rotation

Stripe emits one signature per active secret while a secret roll is in flight
(previous secret active for up to 24 hours), so a verifier that loops over every
`v1=` value rides the rotation with no downtime. Where the provider does not do
this, hold `Secret__c` and `Previous_Secret__c` on the metadata record and try
both, removing the old value in a separate, dated change.

### Pattern D — layer IP allowlisting where the provider publishes ranges

Stripe publishes its webhook source IPs and recommends allowlisting them in
addition to signature verification. This is defence in depth, not a replacement:
IP ranges change, and an allowlist alone proves nothing about payload integrity.

---

## Decision Guidance

| Situation | Approach |
|---|---|
| Provider issues a shared secret | `Crypto.verifyHMac('hmacSHA256', rawBody, secret, mac)` |
| Provider publishes a public certificate / JWKS | `Crypto.verify(alg, data, sig, certDevName)` with the cert in Certificate and Key Management |
| Provider can send a bearer token | Connected App + OAuth 2.0 client credentials flow, **and** keep the signature check |
| Provider cannot authenticate at all | Public Site + guest-user Apex class access; the HMAC check is the only gate |
| Provider signs a derived payload | Reconstruct the derivation from the raw body string, never from a re-serialised object |
| Payload approaches 6 MB | Notification-plus-pull: the event carries an id, Salesforce fetches the body |
| Provider offers several signature schemes | Pin one in code; never let the sender choose the algorithm |

---

## Recommended Workflow

1. **Read the provider's signature documentation and write down four things**:
   header name, value grammar, exactly which bytes are signed, and the algorithm.
   Note whether the secret is used raw or Base64-decoded.
2. **Store the secret as protected Custom Metadata** and expose it through a small
   accessor using `getAll()` so it costs no SOQL query. Never a literal, never a
   Custom Setting.
3. **Write the resource in the fixed order**: read raw body → read signature
   header case-insensitively → reject if absent or malformed → reject if the
   timestamp is outside tolerance → `Crypto.verifyHMac` → *only then* deserialise.
4. **Make it idempotent.** Upsert against the provider's event id on an External
   Id, Unique field, and act only when `Database.UpsertResult` indicates a new row.
5. **Return 2xx immediately** and move all processing to a Queueable or a platform
   event subscriber. No callouts, no email, no Flow inside the handler.
6. **Expose the endpoint deliberately** — Connected App with client credentials, or
   Site guest user with the narrowest possible object access — and record which
   shape you chose and why.
7. **Test the negatives**: tampered body with a captured signature, stale
   timestamp with a valid signature, missing header, and a header supplied in
   different casing from the handler's spelling.

---

## Review Checklist

- [ ] `Crypto.verifyHMac` used, not `generateMac` plus an equality comparison
- [ ] The `Blob` hashed is `req.requestBody`, untouched — no `JSON.serialize` between
- [ ] Signature header read case-insensitively
- [ ] Signature scheme matched exactly (`v1`), not by prefix
- [ ] Timestamp tolerance enforced, and checked before the HMAC
- [ ] Idempotency via External Id, Unique upsert on the provider's event id
- [ ] Secret in protected Custom Metadata; comment records raw vs Base64 form
- [ ] Rejection responses are terse and identical for every failure reason
- [ ] Raw body, signature header, and secret never logged
- [ ] Handler returns 2xx before any processing; work is enqueued
- [ ] Rotation path accepts both current and previous secret
- [ ] Endpoint exposure (Connected App or Site guest user) documented, with guest
      user object access minimised
- [ ] Negative tests: tampered body, stale timestamp, missing header, odd casing

---

## Salesforce-Specific Gotchas

Full detail in [`references/gotchas.md`](references/gotchas.md).

1. **The Base64 rule on `privateKey` is bidirectional** — encode on both sides or
   neither. Key cap is 4 KB.
2. **Apex map keys are case-sensitive; HTTP header names are not.** Intermittent
   401s in production, green tests locally.
3. **Re-serialising the body guarantees 100% verification failure** — which
   usually gets "fixed" by deleting the check.
4. **`generateMac` + `equals` is a hand-rolled comparison**; `verifyHMac` is not.
5. **A valid signature is not a fresh request.** Replay window plus idempotency.
6. **Slow handlers cause retries, not patience.** Verify, stage, 200, enqueue.
7. **Apex REST needs a session** — webhooks have none. Connected App or Site.
8. **Not every provider uses HMAC.** Asymmetric schemes need `Crypto.verify`.
9. **Logging the payload logs the data**, and logging the signature makes a
   captured request replayable from your log store.
10. **Accepting any `vN` scheme is a downgrade attack.** Pin `v1`.
11. **Rotation without dual acceptance is a silent outage** of 401s.

---

## Output Artifacts

| Artifact | Description |
|---|---|
| Provider signature contract note | Header name, value grammar, signed-bytes definition, algorithm, secret encoding form, and the provider doc URL it came from |
| Apex REST resource | Verify → stage → 2xx → enqueue, with a case-insensitive header lookup and terse rejections |
| Secret metadata | Protected `Webhook_Secret__mdt` record plus the accessor class, with a rotation note naming when the previous value may be removed |
| Staging object | External Id, Unique on the provider event id, restricted to the integration's permission set, with a retention policy |
| Negative test class | Tampered body, stale timestamp, missing header, alternate header casing |
| Exposure decision record | Connected App vs Site guest user, with the guest user's granted object access enumerated |

---

## Related Skills

- `integration/webhook-inbound-patterns` — the receiver design this skill sits
  inside: Sites routing, guest-user access model, idempotency schema, and the
  response-window budget. Read it first if you are building the endpoint rather
  than fixing the signature check.
- `integration/outbound-webhook-from-salesforce` — signing a webhook Salesforce
  sends, which is the mirror image of this problem
- `apex/apex-rest-services` — the `@RestResource` surface itself: URL mapping
  rules, supported HTTP methods, and response handling
- `security/guest-user-security` — hardening the Site guest user that a public
  webhook endpoint necessarily exposes
- `integration/retry-and-backoff-patterns` — the provider side of at-least-once
  delivery, and what your 2xx does and does not promise

Files in this skill

  • SKILL.md13.8 KB
  • references/examples.md20.2 KB
  • references/gotchas.md14.2 KB
  • references/llm-anti-patterns.md9.9 KB
  • references/well-architected.md9.4 KB
  • scripts/check_webhook_signature_verification.py956 B
  • templates/webhook-signature-verification-template.md562 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…