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
Authorizationheader 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:
| Break | Expected response |
|---|---|
| Change one byte of the body after computing the signature | 401 invalid signature |
| Use the wrong secret to compute the signature | 401 invalid signature |
| Use a timestamp from an hour ago | 401 stale |
Drop the X-RCM-Signature header entirely | 400 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:
- Expose your local server:
ngrok http 3000→ take thehttps://....ngrok-free.appURL. - Tell OSIGU support / Integraciones: "Please register
<that URL>/webhooks/osigu-rcmas my sandbox webhook destination." They'll share the real HMAC secret out of band. - Replace
RCM_WEBHOOK_SECRETin your env with the real value. - Trigger a real event — upload a test support file.
- Receiver should log a
DOCUMENT_VALIDATION_COMPLETED(orDOCUMENT_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
.envfile 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
| Symptom | Likely cause |
|---|---|
| Signature matches in your unit test but not when RCM sends real events | You're verifying against a re-serialised JSON. Most frameworks parse JSON automatically — re-read the framework gotchas. |
| Signature never matches, even locally | Check 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 random | You'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 events | Your 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 / mangled | Some 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 fail | Confirm 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.