Skip to main content

Verify HMAC signatures (hands-on)

This guide is the practical workshop for HMAC verification — write a verifier, generate signed test events with curl, break it deliberately, fix it, then point sandbox at it.

This applies to HMAC subscriptions (the default). If your subscription uses Basic auth, you validate the Authorization header instead — see Authentication.

Need the reference? The full code in 6 languages (Node, Python, Java, Go, PHP, Ruby) with the framework-specific gotchas lives at Signature verification. This guide assumes you've read that or are using one of those snippets.

The contract in one paragraph

RCM signs every webhook as Base64(HMAC-SHA256(secret, "<timestamp><raw_body>")) — the X-RCM-Timestamp (ISO-8601) and the raw body are concatenated directly, no separator — and sent in the X-RCM-Signature header (raw Base64, no prefix). Reject if (1) the header is missing, (2) the timestamp is more than 5 minutes old, or (3) your recomputed HMAC doesn't match using a constant-time compare.

Workshop in 5 steps

1. Stand up a minimal receiver

This Node.js snippet is the smallest receiver that does verification correctly. Save as receiver.mjs:

import express from 'express';
import crypto from 'crypto';

const app = express();
const SECRET = process.env.RCM_WEBHOOK_SECRET ?? 'demo-secret-replace-me';

app.use('/webhooks/osigu-rcm', express.raw({type: 'application/json'}));

app.post('/webhooks/osigu-rcm', (req, res) => {
const sig = req.header('X-RCM-Signature');
const ts = req.header('X-RCM-Timestamp');

if (!sig || !ts) return res.status(400).send('missing signature headers');
const skewMs = Math.abs(Date.now() - Date.parse(ts));
if (Number.isNaN(skewMs) || skewMs > 300_000) return res.status(401).send('stale');

const expected = crypto
.createHmac('sha256', SECRET)
.update(`${ts}${req.body.toString('utf8')}`, 'utf8')
.digest('base64');

const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('invalid signature');
}

console.log('✓ verified:', JSON.parse(req.body.toString('utf8')).event_type);
res.status(200).send('ok');
});

app.listen(3000, () => console.log('http://localhost:3000'));

Run it:

export RCM_WEBHOOK_SECRET='demo-secret-replace-me'
node receiver.mjs

2. Forge a valid signed request

Generate a request signed with the same secret. Save as send.sh:

#!/usr/bin/env bash
set -e

SECRET="${RCM_WEBHOOK_SECRET:-demo-secret-replace-me}"
TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
BODY='{"event_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","event_type":"DOCUMENT_VALIDATION_COMPLETED","entity_type":"support_file","entity_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","payload":{},"metadata":{},"created_at":"2026-06-25T15:00:00.000Z"}'

SIG=$(printf '%s%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)

curl -X POST http://localhost:3000/webhooks/osigu-rcm \
-H "Content-Type: application/json" \
-H "X-RCM-Event-Type: DOCUMENT_VALIDATION_COMPLETED" \
-H "X-RCM-Event-ID: 3fa85f64-5717-4562-b3fc-2c963f66afa6" \
-H "X-RCM-Timestamp: $TS" \
-H "X-RCM-Signature: $SIG" \
--data-raw "$BODY"
echo

Run it. Receiver should log ✓ verified: DOCUMENT_VALIDATION_COMPLETED and respond with ok.

3. Deliberately break it (4 ways)

Verify the verifier rejects each of these:

BreakExpected response
Change one byte of the body after computing the signature401 invalid signature
Use the wrong secret to compute the signature401 invalid signature
Use a timestamp from an hour ago401 stale
Drop the X-RCM-Signature header entirely400 missing signature headers

To break #1, edit send.sh and change the entity_id value without regenerating $SIG. Receiver should reject.

4. Plug into a real sandbox webhook

Once the local verifier passes the 4 break tests:

  1. Expose your local server: ngrok http 3000 → take the https://....ngrok-free.app URL.
  2. Tell OSIGU support / Integraciones: "Please register <that URL>/webhooks/osigu-rcm as my sandbox webhook destination." They'll share the real HMAC secret out of band.
  3. Replace RCM_WEBHOOK_SECRET in your env with the real value.
  4. Trigger a real event — upload a test support file.
  5. Receiver should log a DOCUMENT_VALIDATION_COMPLETED (or DOCUMENT_VALIDATION_REJECTED) event signed with the real secret.

If it doesn't, walk through "Common failures" below.

5. Lock it down for production

Before shipping the verifier to prod:

  • Move the secret out of code. Read from your secret manager (AWS Secrets Manager, GCP Secret Manager, Vault, k8s secrets). Never from a .env file checked into git.
  • Tighten the timestamp window if you're worried about replay attacks — 5 minutes is generous; 60 seconds is conservative.
  • Log the verification result (pass/fail) but never log the secret or the full signature header.
  • Add a metric for verification_failures_total. Alert if it goes above zero — either someone's spoofing your URL or your secret rotated wrong.

Common failures

SymptomLikely cause
Signature matches in your unit test but not when RCM sends real eventsYou're verifying against a re-serialised JSON. Most frameworks parse JSON automatically — re-read the framework gotchas.
Signature never matches, even locallyCheck the two most common mistakes: a separator between timestamp and body (there is none), or hex instead of Base64 encoding.
Signature matches sometimes, fails sometimes, looks randomYou're using == instead of constant-time compare. Switch to crypto.timingSafeEqual (Node), hmac.compare_digest (Python), MessageDigest.isEqual (Java).
Always 401 stale even on fresh eventsYour server clock is off, or you're parsing X-RCM-Timestamp as Unix seconds instead of ISO-8601. NTP-sync the box and parse the timestamp as ISO-8601.
Signature header arrives lowercased / mangledSome proxies normalise header case. Read it case-insensitively (req.header('x-rcm-signature') works fine in Node; request.headers.get(...) in Python is case-insensitive).
Local test passes with openssl dgst, real events still failConfirm the secret registered for the subscription matches the one you're using locally. Each subscription has its own secret.

What to do if you suspect the secret was leaked

Contact support@osigu.com / Integraciones with your subscription details — they'll rotate it. While you wait, your old secret keeps working so events aren't dropped. See secret rotation without downtime for the cutover flow.