En webhook uden signatur er en åben dør. Enhver, der kender din URL, kan sende en falsk betaling, en falsk ordre eller en falsk “konto slettet”-besked, og din server vil behandle den som ægte. HMAC (Hash-based Message Authentication Code) lukker den dør. Med en delt hemmelig nøgle og en hashfunktion kan du bevise to ting på én gang: at beskeden kommer fra den rette afsender, og at ingen har ændret et eneste byte undervejs.
Denne tutorial bygger en komplet, kørende HMAC-løsning i Node.js fra bunden i 12 trin. Du lærer at generere en stærk nøgle, beregne en HMAC med crypto.createHmac, verificere den i konstant tid med crypto.timingSafeEqual, og bygge en Express-server, der signerer udgående webhooks og afviser forfalskede indgående. Du får også 6 faldgruber, 8 fejlfindingspunkter, avancerede tips og et fuldt projekt, du kan kopiere direkte. Alt er testet på Node.js 22 LTS og bruger udelukkende det indbyggede crypto-modul, så du skal ikke installere et eneste kryptobibliotek.
Hvad er HMAC, og hvorfor bruge det?
HMAC er en nøglebaseret beskedautentificeringskode. Den kombinerer en kryptografisk hashfunktion, for eksempel SHA-256, med en hemmelig nøgle, som kun afsender og modtager kender. Resultatet er en kort streng bytes, en MAC, der følger med beskeden. Modtageren beregner sin egen MAC over den modtagne besked med samme nøgle. Hvis de to MAC’er er identiske, ved modtageren, at beskeden er autentisk og uændret. Standarden er defineret i RFC 2104 og i NIST’s FIPS 198-1, og den er en af de mest udbredte byggeklodser i moderne API-sikkerhed.
Bemærk en vigtig skelnen: HMAC er ikke kryptering. Den skjuler ikke indholdet af din besked. Den giver integritet og autenticitet, ikke fortrolighed. Vil du både skjule og autentificere data, kombinerer du HMAC med kryptering, eller du bruger en AEAD-ordning som AES-GCM. HMAC løser et andet, men lige så kritisk problem: “kan jeg stole på, at denne besked virkelig kom fra den, jeg tror, og at den ikke er pillet ved?”
Du møder HMAC overalt, selvom du ikke altid lægger mærke til det. Stripe, GitHub, Shopify og Slack signerer alle deres webhooks med HMAC-SHA256. AWS bruger HMAC i Signature Version 4 til at signere API-kald. JSON Web Tokens med algoritmen HS256 er reelt en HMAC-SHA256 over header og payload. Når du forstår mønsteret i denne tutorial, kan du verificere alle disse signaturer korrekt, og du undgår de timingfejl, der gør mange hjemmelavede implementeringer usikre.
Hvorfor ikke bare en almindelig hash? Fordi en almindelig SHA-256 ikke har nogen nøgle. Enhver, der kan ændre beskeden, kan beregne en ny gyldig hash og sende den med. HMAC’s hemmelige nøgle er det, der gør forfalskning umulig uden adgang til nøglen. Forskellen mellem en hash og en HMAC er nøglen, og den forskel er hele pointen.
Forudsætninger og versioner
Du behøver meget lidt for at følge med, fordi alt det kryptografiske kommer fra Node.js’ kerne. Sørg for at have følgende på plads, før du går i gang.
- Node.js 20 LTS eller nyere. Eksemplerne er testet på Node.js 22 LTS. Tjek din version med
node --version. Kører du noget ældre, så opgrader, da gamle udgaver mangler sikkerhedsrettelser icrypto. - npm 10 eller nyere. Følger med Node.js. Tjek med
npm --version. - Express 4.18 eller 5.x til webhook-serveren i trin 7 og 8. Det er den eneste eksterne afhængighed i hele projektet.
- En editor og en terminal. VS Code eller hvad du foretrækker.
- Grundlæggende JavaScript. Du skal kunne læse async-funktioner, Buffer-objekter og en simpel Express-rute.
Det indbyggede crypto-modul leverer hele HMAC-funktionaliteten. Du importerer det med const crypto = require('node:crypto') eller, hvis du bruger ES-moduler, import crypto from 'node:crypto'. Præfikset node: er den anbefalede måde at importere kernemoduler på, fordi det gør det tydeligt, at modulet kommer fra runtime og ikke fra en npm-pakke.
| Funktion | Formål | Returnerer |
|---|---|---|
crypto.createHmac(alg, key) | Opretter et HMAC-objekt med valgt hash og nøgle | Hmac-objekt (stream-lignende) |
hmac.update(data) | Tilfører data til beregningen, kan kaldes flere gange | Samme Hmac-objekt |
hmac.digest(encoding) | Afslutter og udregner MAC’en | Buffer eller streng |
crypto.timingSafeEqual(a, b) | Sammenligner to buffere i konstant tid | Boolean |
crypto.randomBytes(n) | Genererer kryptografisk tilfældige bytes | Buffer |
Trin 1: Opsæt projektet
Start med en ren mappe og initialiser et npm-projekt. Du installerer kun Express; alt andet er indbygget.
mkdir node-hmac-webhooks && cd node-hmac-webhooks
npm init -y
npm install [email protected]
# Tjek at versionerne er som forventet
node --version # f.eks. v22.11.0
npm --version # f.eks. 10.9.0
Åbn package.json og tilføj "type": "commonjs", hvis det ikke allerede står der, så require virker i alle eksempler. Vil du bruge ES-moduler i stedet, så sæt "type": "module" og erstat hvert require med import. Resten af koden er identisk. Opret nu en mappe src, hvor vi lægger filerne fra de næste trin.
Trin 2: Generér en stærk hemmelig nøgle
HMAC’s sikkerhed afhænger fuldstændigt af nøglen. Skriv den aldrig i hånden, og brug aldrig en kort streng som “secret123”. Generér i stedet mindst 32 tilfældige bytes med crypto.randomBytes, hvilket svarer til hashens outputstørrelse for SHA-256 og er et fornuftigt minimum.
// src/generate-key.js
const crypto = require('node:crypto');
// 32 bytes = 256 bit, et solidt minimum til HMAC-SHA256
const key = crypto.randomBytes(32);
console.log('Hex: ', key.toString('hex'));
console.log('Base64:', key.toString('base64'));
Kør node src/generate-key.js. Du får noget i stil med dette:
Hex: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
Base64: n4bQgYhMfWWaL+qgwVtKFaO/TxsrC4Is0V1sFbDwCgg=
Gem nøglen som en miljøvariabel, aldrig i kildekoden og aldrig i et git-repository. Læg den i en .env-fil, der står i .gitignore, eller hent den fra en hemmelighedstjeneste i produktion. I koden læser du den med process.env.HMAC_SECRET. Begge parter, afsender og modtager, skal have præcis samme nøgle, for HMAC er symmetrisk.
Trin 3: Beregn din første HMAC
Nu til kernen. Du opretter et HMAC-objekt med crypto.createHmac, fodrer det med data via update, og afslutter med digest. Læg mærke til, at outputkodningen vælges i digest; uden et argument får du en rå Buffer, med 'hex' eller 'base64' får du en streng.
// src/sign.js
const crypto = require('node:crypto');
function sign(message, secret) {
return crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
}
const secret = 'min-hemmelige-noegle';
const message = '{"event":"payment.succeeded","amount":4999}';
console.log(sign(message, secret));
// 3f8a... en 64-tegns hex-streng (256 bit)
Algoritmenavnet, her 'sha256', bestemmer både sikkerheden og længden på outputtet. SHA-256 giver 32 bytes, altså 64 hex-tegn. Vil du have en længere MAC, vælger du 'sha384' eller 'sha512'. For de fleste webhooks og API-signaturer er SHA-256 standardvalget, og det er, hvad Stripe, GitHub og de fleste andre bruger.
Vælg den rette hashfunktion
HMAC’s styrke arver fra den underliggende hash. Undgå MD5 og SHA-1, der er forældede. Hold dig til SHA-2-familien. Tabellen viser de praktiske valg og deres outputstørrelser.
| Algoritme | Output (bytes) | Output (hex-tegn) | Typisk brug |
|---|---|---|---|
| HMAC-SHA256 | 32 | 64 | Webhooks, JWT HS256, standardvalg |
| HMAC-SHA384 | 48 | 96 | Højere sikkerhedsmargin |
| HMAC-SHA512 | 64 | 128 | Store sikkerhedskrav, hurtig på 64-bit-CPU |
| HMAC-SHA1 | 20 | 40 | Frarådes, kun til ældre integrationer |
| HMAC-MD5 | 16 | 32 | Brug aldrig, forældet |
Trin 4: Brug streaming til store payloads
Hmac-objektet er stream-lignende. Du kan kalde update flere gange, hvilket er den idiomatiske måde at signere store request-bodies eller filer på, uden at holde det hele i hukommelsen på én gang. Hver update tilføjer bytes til den løbende beregning.
// src/sign-stream.js
const crypto = require('node:crypto');
const fs = require('node:fs');
function signFile(path, secret) {
return new Promise((resolve, reject) => {
const hmac = crypto.createHmac('sha256', secret);
const stream = fs.createReadStream(path);
stream.on('data', (chunk) => hmac.update(chunk));
stream.on('end', () => resolve(hmac.digest('hex')));
stream.on('error', reject);
});
}
signFile('./stor-fil.bin', process.env.HMAC_SECRET)
.then((mac) => console.log('Fil-MAC:', mac))
.catch(console.error);
Til små beskeder er one-shot-formen fra trin 3 helt fin. Til filer på flere hundrede megabyte sparer streaming-formen hukommelse og holder din proces stabil. Resultatet er bit for bit det samme, uanset om du kalder update én eller hundrede gange, så længe de samlede bytes er identiske.
Trin 5: Verificér en signatur i konstant tid
Her laver de fleste begyndere den farligste fejl. Det er fristende at sammenligne den forventede og den modtagne MAC med ===, men det er en sikkerhedsbrist. JavaScripts strengsammenligning afslutter, så snart den finder den første forskel. En angriber kan måle, hvor lang tid sammenligningen tager, og dermed gætte signaturen ét tegn ad gangen. Det kaldes et timingangreb.
Løsningen er crypto.timingSafeEqual, der altid bruger lige lang tid uanset hvor mange bytes der matcher. Funktionen kræver to buffere af nøjagtig samme længde og kaster en fejl, hvis de ikke er det. Derfor skal du dekode begge signaturer til buffere og afvise længdeforskelle, før du sammenligner.
// src/verify.js
const crypto = require('node:crypto');
function verify(message, receivedHex, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(message)
.digest(); // rå Buffer
let received;
try {
received = Buffer.from(receivedHex, 'hex');
} catch {
return false; // ugyldig hex-kodning
}
// Forskellig længde betyder altid afvisning, og undgår at
// timingSafeEqual kaster en RangeError
if (received.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(expected, received);
}
module.exports = { verify };
Læg mærke til tre detaljer. Vi kalder digest() uden argument for at få en Buffer, så vi kan sammenligne bytes direkte. Vi pakker Buffer.from i en try/catch, fordi en forvansket hex-streng kan give problemer. Og vi tjekker længden først, fordi timingSafeEqual kaster en RangeError, hvis bufferne ikke er lige lange. Med disse tre ting på plads har du en korrekt og sikker verifikation.
Trin 6: Test signering og verifikation sammen
Før vi bygger en server, så bevis at de to halvdele passer sammen. En korrekt signatur skal accepteres, og enhver ændring af beskeden eller signaturen skal afvises.
// src/roundtrip.js
const crypto = require('node:crypto');
const { verify } = require('./verify');
const secret = crypto.randomBytes(32);
const message = '{"event":"order.created","id":"ord_123"}';
const signature = crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
console.log('Gyldig: ', verify(message, signature, secret));
console.log('Ændret besked: ', verify(message + ' ', signature, secret));
console.log('Ændret sig.: ', verify(message, signature.slice(0, -1) + '0', secret));
console.log('Forkert noegle:', verify(message, signature, crypto.randomBytes(32)));
Det forventede output bekræfter, at kun den ægte kombination går igennem:
Gyldig: true
Ændret besked: false
Ændret sig.: false
Forkert noegle: false
Tre falske og ét sandt: præcis som det skal være. Et enkelt ekstra mellemrum i beskeden ændrer hele MAC’en, fordi hashfunktionen er følsom over for hvert eneste byte. Det er denne egenskab, der giver dig integritetsbeskyttelse.
Trin 7: Byg en Express-server, der signerer udgående webhooks
Nu bygger vi afsendersiden. Når din applikation sender en webhook til en kunde, vedlægger du en signaturheader, så kunden kan verificere afsenderen. Konventionen er at lægge MAC’en i en header som X-Signature og ofte et timestamp i X-Timestamp.
// src/sender.js
const crypto = require('node:crypto');
const https = require('node:https');
const SECRET = process.env.HMAC_SECRET;
function sendWebhook(url, payload) {
const body = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
// Signer timestamp og body sammen, saa timestamp ikke kan aendres
const signature = crypto
.createHmac('sha256', SECRET)
.update(timestamp + '.' + body)
.digest('hex');
const req = https.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timestamp': timestamp,
'X-Signature': 'sha256=' + signature,
},
});
req.write(body);
req.end();
}
module.exports = { sendWebhook };
Bemærk to designvalg, der følger industristandarden. Vi signerer timestamp + '.' + body sammen, ikke kun body, så en angriber ikke kan genbruge en gammel signatur med et nyt timestamp. Og vi præfikser signaturen med sha256=, præcis som GitHub gør, så modtageren ved, hvilken algoritme der blev brugt. Det gør det nemt at skifte algoritme senere uden at brække eksisterende integrationer.
Trin 8: Verificér indgående webhooks med den rå body
Modtagersiden indeholder den mest oversete fælde i hele emnet: du skal verificere over de nøjagtige rå bytes, der kom ind, ikke over et parset JavaScript-objekt. Når Express parser JSON og du derefter laver JSON.stringify igen, kan rækkefølgen af felter, mellemrum og Unicode-escapes ændre sig. Selv én ændret byte giver en helt anden MAC, og verifikationen fejler, selvom beskeden er ægte.
Løsningen er at gemme den rå body, før den parses. Med express.json kan du gøre det via verify-callbacken, der får adgang til den rå Buffer.
// src/receiver.js
const express = require('express');
const crypto = require('node:crypto');
const app = express();
const SECRET = process.env.HMAC_SECRET;
const MAX_AGE_SECONDS = 300; // afvis webhooks aeldre end 5 minutter
// Gem den raa body, mens JSON stadig parses normalt
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; },
}));
function isValid(req) {
const timestamp = req.get('X-Timestamp');
const header = req.get('X-Signature') || '';
const received = header.replace('sha256=', '');
if (!timestamp || !received) return false;
// Replay-beskyttelse: afvis gamle eller fremtidige timestamps
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || Math.abs(age) > MAX_AGE_SECONDS) return false;
const signed = timestamp + '.' + req.rawBody.toString('utf8');
const expected = crypto.createHmac('sha256', SECRET).update(signed).digest();
const receivedBuf = Buffer.from(received, 'hex');
if (receivedBuf.length !== expected.length) return false;
return crypto.timingSafeEqual(expected, receivedBuf);
}
app.post('/webhook', (req, res) => {
if (!isValid(req)) {
return res.status(401).json({ error: 'ugyldig signatur' });
}
// Foerst her er det sikkert at handle paa beskeden
console.log('Gyldig webhook:', req.body.event);
res.status(200).json({ received: true });
});
app.listen(3000, () => console.log('Lytter paa port 3000'));
Verifikationen sker, før nogen forretningslogik kører. En fejlet signatur kortslutter requesten med 401, så uautentificeret input aldrig udløser bivirkninger. Det er det rigtige sted at placere kontrollen, helt ude ved transportgrænsen.
Trin 9: Håndtér både hex- og base64-kodning
Forskellige udbydere koder deres signaturer forskelligt. GitHub bruger hex, Slack bruger hex med præfiks, og nogle bruger base64. Vælg én kanonisk kodning til din egen API, og dokumentér den tydeligt. Skal du verificere en tredjeparts webhook, så match præcis deres kodning. Her er en hjælpefunktion, der håndterer begge.
// src/decode.js
const crypto = require('node:crypto');
function safeCompare(expectedBuf, received, encoding) {
let receivedBuf;
try {
receivedBuf = Buffer.from(received, encoding); // 'hex' eller 'base64'
} catch {
return false;
}
if (receivedBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
module.exports = { safeCompare };
En subtil ting ved Buffer.from med 'base64': ugyldige tegn ignoreres i stedet for at kaste en fejl, så en forvansket base64-streng kan give en buffer med forkert længde. Længdetjekket fanger det og afviser requesten, hvilket er præcis den adfærd vi vil have. Derfor er længdetjekket før timingSafeEqual ikke bare en formalitet, det er en del af sikkerheden.
Trin 10: Tilføj timestamp og replay-beskyttelse
En gyldig signatur beviser kun, at beskeden er ægte, ikke at den er ny. Uden timestamp kan en angriber, der opsnapper en gyldig webhook, sende den igen og igen. Hvis beskeden var “overfør 5.000 kr.”, er det et alvorligt problem. Løsningen, som vi allerede byggede ind i trin 8, er at inkludere et timestamp i det signerede data og afvise beskeder, der er for gamle.
Et vindue på 5 minutter er en almindelig balance mellem at tillade netværksforsinkelse og at begrænse replay-vinduet. Vil du have stærkere beskyttelse, gemmer du hvert sets X-Signature i en kortlivet cache, for eksempel Redis med en TTL svarende til vinduet, og afviser en signatur, du allerede har set. Så kan en besked kun behandles én gang.
// src/replay-guard.js
const seen = new Map(); // i produktion: brug Redis med TTL
function checkReplay(signature, maxAgeSeconds = 300) {
const now = Date.now();
// Ryd op i gamle poster
for (const [sig, ts] of seen) {
if (now - ts > maxAgeSeconds * 1000) seen.delete(sig);
}
if (seen.has(signature)) return false; // allerede behandlet
seen.set(signature, now);
return true;
}
module.exports = { checkReplay };
Kombinationen af timestamp-vindue og set-baseret duplikatkontrol giver robust replay-beskyttelse. Timestamp-vinduet holder cachen lille, fordi du kun behøver at huske signaturer inden for vinduet. Det er det samme mønster, Stripe anbefaler i sin webhook-dokumentation.
Trin 11: Roter nøgler uden nedetid
Nøgler skal kunne udskiftes, hvis de lækker, eller blot som rutine. Problemet er, at du ikke kan skifte nøgle på begge sider i nøjagtig samme millisekund. Løsningen er at understøtte flere gyldige nøgler i en overgangsperiode. Modtageren accepterer en signatur, hvis den matcher mod en hvilken som helst af de aktive nøgler.
// src/rotate.js
const crypto = require('node:crypto');
// Flere aktive noegler i en overgangsperiode, nyeste foerst
const KEYS = (process.env.HMAC_KEYS || '').split(',').filter(Boolean);
function verifyWithRotation(signedData, receivedHex) {
const received = Buffer.from(receivedHex, 'hex');
for (const key of KEYS) {
const expected = crypto.createHmac('sha256', key).update(signedData).digest();
if (received.length === expected.length &&
crypto.timingSafeEqual(expected, received)) {
return true;
}
}
return false;
}
module.exports = { verifyWithRotation };
Fremgangsmåden er enkel. Tilføj den nye nøgle til HMAC_KEYS ved siden af den gamle. Skift afsenderen til at signere med den nye nøgle. Når al trafik bruger den nye nøgle, fjerner du den gamle. På intet tidspunkt afvises gyldige beskeder. Bemærk, at vi stadig sammenligner i konstant tid for hver kandidatnøgle, så rotationen ikke åbner et nyt timingangreb.
Trin 12: Skriv automatiske tests
Sikkerhedskode uden tests er en ulykke, der venter på at ske. Node.js har en indbygget test-runner siden version 18, så du behøver intet ekstra bibliotek. Skriv mindst tre tests: en gyldig signatur skal accepteres, en manipuleret body skal afvises, og en ugyldig længde skal afvises uden at kaste.
// src/verify.test.js
const test = require('node:test');
const assert = require('node:assert');
const crypto = require('node:crypto');
const { verify } = require('./verify');
const secret = crypto.randomBytes(32);
const msg = '{"event":"ping"}';
const sig = crypto.createHmac('sha256', secret).update(msg).digest('hex');
test('accepterer gyldig signatur', () => {
assert.strictEqual(verify(msg, sig, secret), true);
});
test('afviser manipuleret body', () => {
assert.strictEqual(verify(msg + 'x', sig, secret), false);
});
test('afviser forkert laengde uden at kaste', () => {
assert.strictEqual(verify(msg, 'abc', secret), false);
});
Kør testene med node --test. Du får en samlet rapport over beståede og fejlede tests. Tilføj gerne flere: test base64-kodning, test rotation med to nøgler, og test at et fremtidigt timestamp afvises. Jo flere kanttilfælde du dækker, jo mere sikker er din implementering.
Det komplette, kørende projekt
Her er et samlet, selvstændigt eksempel, der binder afsender og modtager sammen i én fil, så du kan køre det direkte og se hele flowet. Sæt først miljøvariablen, og kør derefter filen.
// app.js -> koer med: HMAC_SECRET=$(openssl rand -hex 32) node app.js
const express = require('express');
const crypto = require('node:crypto');
const app = express();
const SECRET = process.env.HMAC_SECRET;
const MAX_AGE = 300;
if (!SECRET) {
console.error('Saet HMAC_SECRET, fx: HMAC_SECRET=$(openssl rand -hex 32)');
process.exit(1);
}
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
function signPayload(timestamp, body) {
return crypto.createHmac('sha256', SECRET)
.update(timestamp + '.' + body).digest('hex');
}
function isValid(req) {
const ts = req.get('X-Timestamp');
const received = (req.get('X-Signature') || '').replace('sha256=', '');
if (!ts || !received) return false;
const age = Math.floor(Date.now() / 1000) - Number(ts);
if (!Number.isFinite(age) || Math.abs(age) > MAX_AGE) return false;
const expected = crypto.createHmac('sha256', SECRET)
.update(ts + '.' + req.rawBody.toString('utf8')).digest();
const receivedBuf = Buffer.from(received, 'hex');
if (receivedBuf.length !== expected.length) return false;
return crypto.timingSafeEqual(expected, receivedBuf);
}
app.post('/webhook', (req, res) => {
if (!isValid(req)) return res.status(401).json({ error: 'ugyldig signatur' });
res.json({ received: true, event: req.body.event });
});
const server = app.listen(3000, async () => {
// Selvtest: send en korrekt signeret webhook til os selv
const body = JSON.stringify({ event: 'selvtest' });
const ts = Math.floor(Date.now() / 1000).toString();
const sig = signPayload(ts, body);
const res = await fetch('http://localhost:3000/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timestamp': ts,
'X-Signature': 'sha256=' + sig,
},
body,
});
console.log('Selvtest status:', res.status, await res.json());
server.close();
});
Kør det, og du ser Selvtest status: 200 { received: true, event: 'selvtest' }. Skift en enkelt byte i body efter signeringen, og status bliver 401. Det er hele HMAC-mønsteret i omkring 50 linjer kode, og det bruger udelukkende Node.js-kerne og Express.
6 almindelige faldgruber
De fleste HMAC-fejl er ikke i kryptografien, men i, hvordan koden bruger den. Her er de seks, der oftest rammer udviklere i praksis.
- At sammenligne med
===i stedet fortimingSafeEqual. Det er den klassiske timingbrist. Almindelig strengsammenligning afslører via tidsforbrug, hvor mange tegn der matchede, og åbner for, at en angriber gætter signaturen byte for byte. - At verificere over den parsede body i stedet for de rå bytes.
JSON.parseefterfulgt afJSON.stringifyændrer ofte bytes, og din ellers ægte webhook bliver afvist. Gem altid den rå Buffer. - At glemme længdetjekket før
timingSafeEqual. Funktionen kaster enRangeError, hvis bufferne har forskellig længde, og en ufanget fejl kan crashe din handler eller afsløre detaljer i en stack trace. - At bruge en svag eller kort nøgle. “secret” eller “test123” giver ingen reel beskyttelse. Brug mindst 32 tilfældige bytes fra
crypto.randomBytes. - At signere body uden timestamp. Uden timestamp og replay-beskyttelse kan en opsnappet, gyldig webhook genafspilles. Inkludér altid et timestamp i det signerede data.
- At lægge nøglen i kildekoden eller git. En nøgle i et repository er en lækket nøgle. Brug miljøvariabler eller en hemmelighedstjeneste, og roter straks, hvis en nøgle nogensinde er blevet committet.
Fejlfinding: 8 typiske problemer
Når verifikationen fejler, er årsagen næsten altid én af nedenstående. Tabellen kobler symptom til årsag og løsning, så du hurtigt kan finde fejlen.
| Symptom | Sandsynlig årsag | Løsning |
|---|---|---|
| Gyldig webhook afvises altid | Verifikation over parset body, ikke rå bytes | Gem rawBody via express.json verify-callback |
| RangeError fra timingSafeEqual | Buffere af forskellig længde | Tjek længden, og afvis, før du sammenligner |
| Signaturer matcher aldrig | Forskellig nøgle på de to sider | Bekræft, at samme HMAC_SECRET bruges begge steder |
| Matcher lokalt, fejler i produktion | Forskellig kodning, hex mod base64 | Dokumentér og match én kanonisk kodning |
| Alt afvises efter nøgleskift | Afsender og modtager ude af sync | Brug nøglerotation med flere aktive nøgler |
| Gamle beskeder accepteres | Manglende timestamp-kontrol | Tilføj timestamp-vindue og replay-guard |
| Forkert MAC for æ, ø, å i body | Forkert tegnkodning ved toString | Brug konsekvent utf8 begge steder |
| Header er undefined | Proxy fjerner X-Signature-header | Tillad headeren eksplicit i proxy eller load balancer |
Et godt fejlfindingstrick: log den forventede og den modtagne MAC side om side i et testmiljø, aldrig i produktion. Står de forskelligt allerede i de første par tegn, er det typisk en kodnings- eller nøglefejl. Er de identiske bortset fra længden, er det en kodningsforskel. Log aldrig selve nøglen.
Avancerede tips
Brug WebCrypto til portabel kode
Hvis din kode også skal køre i browseren eller i edge-runtimes som Cloudflare Workers og Deno, kan du bruge WebCrypto-API’et via crypto.subtle i stedet for createHmac. Det er asynkront og en smule mere omstændeligt, men virker på tværs af alle moderne JavaScript-runtimes.
// WebCrypto-variant, virker i browser, Node, Deno og Workers
async function signSubtle(message, secretBytes) {
const key = await crypto.subtle.importKey(
'raw', secretBytes,
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign'],
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
return Buffer.from(mac).toString('hex');
}
HMAC som nøgleafledning
HMAC er byggestenen i HKDF, den standardiserede nøgleafledningsfunktion i RFC 5869. Har du brug for at udlede flere nøgler fra én hovednøgle, så brug crypto.hkdfSync i stedet for at finde på din egen konstruktion. Den bruger HMAC under motorhjelmen og er gennemtænkt af kryptografer, hvilket din egen løsning sjældent er.
Overvej AEAD, når du også skal kryptere
Skal data både skjules og autentificeres, så lad være med at klistre HMAC oven på din egen kryptering. Brug i stedet en AEAD-ordning som AES-256-GCM, der giver kryptering og integritet i ét trin og lukker de fælder, der opstår, når man kombinerer kryptering og MAC i hånden. Node.js understøtter AES-GCM direkte via crypto.createCipheriv.
HMAC mod digitale signaturer: hvornår vælger du hvad?
HMAC og digitale signaturer løser beslægtede problemer, men de er ikke ens. HMAC bruger én delt hemmelig nøgle; begge parter kan både signere og verificere. En digital signatur, for eksempel Ed25519 eller ECDSA, bruger et nøglepar; kun indehaveren af den private nøgle kan signere, og alle med den offentlige nøgle kan verificere. Valget afhænger af, om de to parter stoler på hinanden med samme hemmelighed.
| Egenskab | HMAC | Digital signatur (Ed25519) |
|---|---|---|
| Nøgletype | Én delt hemmelig nøgle | Privat/offentligt nøglepar |
| Hvem kan signere | Begge parter | Kun indehaver af privat nøgle |
| Hvem kan verificere | Begge parter | Alle med offentlig nøgle |
| Uafviselighed | Nej | Ja |
| Hastighed | Meget hurtig | Langsommere, men stadig hurtig |
| Typisk brug | Webhooks, API-signering, JWT HS256 | Software-signering, certifikater, blockchain |
Tommelfingerreglen: stoler begge parter på samme hemmelighed, og er hastighed vigtig, så vælg HMAC. Skal verifikation kunne ske offentligt, eller skal du kunne bevise over for en tredjepart, hvem der signerede, så vælg en digital signatur. For webhooks mellem to systemer, du selv kontrollerer, er HMAC det enkle og rigtige valg. Vil du dykke ned i den anden side, så læs vores gennemgang af Ed25519-signaturer i Node.js.
Ofte stillede spørgsmål
Er HMAC stadig sikkert i 2026?
Ja. HMAC-SHA256 betragtes som sikkert og anbefales fortsat af NIST i FIPS 198-1. I modsætning til mange andre kryptografiske konstruktioner er HMAC overraskende robust, selv når den underliggende hash får svagheder. HMAC-SHA1 er stadig teoretisk modstandsdygtigt, selvom SHA-1 selv er brudt til kollisioner, men du bør alligevel bruge SHA-256 eller bedre til ny kode.
Hvad er forskellen på HMAC og en almindelig hash?
En almindelig hash som SHA-256 har ingen nøgle, så enhver kan beregne den. En angriber kan ændre beskeden og lave en ny gyldig hash. HMAC tilføjer en hemmelig nøgle, så kun den, der kender nøglen, kan lave en gyldig MAC. Nøglen er forskellen, og den er hele grunden til, at HMAC kan autentificere.
Hvorfor kan jeg ikke bare bruge ===-sammenligning?
Fordi === afslutter ved første forskel og dermed bruger forskellig tid afhængigt af, hvor mange tegn der matchede. En angriber kan måle den tid og gradvist gætte den korrekte signatur. crypto.timingSafeEqual bruger altid samme tid og lukker det hul.
Skal jeg signere request-body eller det parsede objekt?
Altid de rå bytes af request-body, aldrig det parsede objekt. JSON-parsing og genserialisering kan ændre bytes, og så fejler verifikationen, selvom beskeden er ægte. Gem den rå Buffer, før du parser, og verificér over den.
Hvor lang skal min HMAC-nøgle være?
Mindst lige så lang som hashens output. Til HMAC-SHA256 betyder det mindst 32 bytes (256 bit). Generér nøglen med crypto.randomBytes(32), og gem den som en miljøvariabel. Længere nøgler skader ikke, men giver ringe ekstra sikkerhed ud over hashens blokstørrelse.
Kan HMAC erstatte digitale signaturer?
Kun når begge parter trygt kan dele én hemmelig nøgle. HMAC giver ikke uafviselighed, fordi begge parter kan lave gyldige MAC’er, så du kan ikke bevise over for en tredjepart, hvem der signerede. Skal du have offentlig verifikation eller uafviselighed, så brug en digital signatur som Ed25519 i stedet.
Virker det samme mønster til Stripe- og GitHub-webhooks?
Ja, med små tilpasninger. Begge bruger HMAC-SHA256 over den rå body. GitHub sender signaturen i headeren X-Hub-Signature-256 med præfiks sha256=, og Stripe lægger timestamp og signatur i Stripe-Signature. Læs hver udbyders header-format, men selve verifikationen, createHmac plus timingSafeEqual over de rå bytes, er nøjagtig den samme.
Relateret indhold
- Ed25519 i Node.js: signaturer i 12 trin
- Sikker session i Node.js: 12 trin på 30 min
- Hashfunktioner: egenskaber, formål og praktisk brug
- SHA-256 forklaret: hjørnestenen i moderne hashing
- Digitale signaturer: hvordan hashing og nøgler skaber tillid
- Gratis SSL/TLS-certifikat: 12 trin med Certbot
- Kryptografi: hashfunktioner, SHA og digital tillid
Eksterne kilder
- Node.js crypto-modulets dokumentation
- RFC 2104: HMAC-specifikationen
- NIST FIPS 198-1: The Keyed-Hash Message Authentication Code
- RFC 6234: US Secure Hash Algorithms (SHA)
- Node.js sikkerhedsmeddelelser
Du har nu en komplet, sikker HMAC-løsning i Node.js: nøglegenerering, signering, konstant-tids-verifikation, en Express-server til både udgående og indgående webhooks, replay-beskyttelse, nøglerotation og tests. Det hele kører på Node.js-kerne plus Express, uden et eneste eksternt kryptobibliotek. Kopiér det komplette projekt fra denne tutorial, sæt din egen nøgle, og du har et fundament, der matcher den måde, Stripe, GitHub og Slack signerer deres webhooks på.




