SfSkills Domains Docs GitHub

Home / integration / webhook-signature-verification

webhook-signature-verification

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.

Open the skill folder on GitHub


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:

PropertyEstablished byFails when
Authenticity — the sender holds the shared secretHMAC over the payloadSecret is wrong, or the wrong encoding of the right secret
Integrity — the bytes were not altered in transitHMAC over the raw bodyThe body was parsed and re-serialised before hashing
Freshness — this is not a replay of an earlier valid requestTimestamp tolerance + idempotency keyNeither 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

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 2, Gotcha 4.

For asymmetric providers:

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 0, 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

SituationApproach
Provider issues a shared secretCrypto.verifyHMac('hmacSHA256', rawBody, secret, mac)
Provider publishes a public certificate / JWKSCrypto.verify(alg, data, sig, certDevName) with the cert in Certificate and Key Management
Provider can send a bearer tokenConnected App + OAuth 2.0 client credentials flow, and keep the signature check
Provider cannot authenticate at allPublic Site + guest-user Apex class access; the HMAC check is the only gate
Provider signs a derived payloadReconstruct the derivation from the raw body string, never from a re-serialised object
Payload approaches 6 MBNotification-plus-pull: the event carries an id, Salesforce fetches the body
Provider offers several signature schemesPin one in code; never let the sender choose the algorithm

  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


Salesforce-Specific Gotchas

Full detail in 0.

  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

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

Package files