Issue HMAC-Signed, Scoped API Keys for a Public REST API
Replace static bearer tokens with per-client HMAC request signing, scoped permissions, and replay protection in Node.js.
What you'll build
A Node.js + Express API where every request is authenticated by a per-client HMAC-SHA256 signature instead of a static bearer token — with scoped permissions, a timestamp window, and nonce tracking so a captured request can't be replayed or reused against another endpoint.
Prerequisites
- Node.js 24 LTS (anything ≥ 20 works — the code uses only the built-in
node:cryptomodule and globalfetch). Verified against Node 24 (Active LTS) and Express 5.2.1, the current npm default. - npm and
curlon any OS; commands shown are shell-agnostic. - Comfort with Express middleware. No database needed — keys live in a JSON file here; swap in your real store later.
Why HMAC over bearer tokens: a bearer token is a password sent on every request — anyone who sees it (proxy logs, browser extensions, a leaked HAR file) owns the account. With HMAC signing, the secret never leaves the client; each request carries a one-time signature over its own method, path, timestamp, and body, so an intercepted request is useless seconds later and can't be modified at all.
1. Scaffold the project
mkdir hmac-api && cd hmac-api
npm init -y
npm pkg set type=module
npm install express@5
type=module enables import syntax and top-level await in plain .js files.
2. Issue scoped keys
Each client gets a public key ID and a random 256-bit secret, with scopes attached to the server-side record. Create issue-key.js:
import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
const scopes = process.argv.slice(2);
if (scopes.length === 0) {
console.error('usage: node issue-key.js <scope> [scope...]');
process.exit(1);
}
const keyId = 'ak_' + randomBytes(8).toString('hex');
const secret = randomBytes(32).toString('hex');
const db = existsSync('./keys.json')
? JSON.parse(readFileSync('./keys.json', 'utf8'))
: {};
db[keyId] = { secret, scopes, createdAt: new Date().toISOString() };
writeFileSync('./keys.json', JSON.stringify(db, null, 2));
console.log(`Key ID: ${keyId}`);
console.log(`Secret: ${secret}`);
console.log(`Scopes: ${scopes.join(', ')}`);
Issue a read-only key and a read-write key:
node issue-key.js reports:read
node issue-key.js reports:read reports:write
One caveat vs. password hashing: HMAC verification needs the plaintext secret server-side, so you can't store just a hash. In production, encrypt keys.json's equivalent at rest (KMS-wrapped) or derive per-key secrets from a master key with HKDF.
3. Build the verifying server
The client signs a canonical string — METHOD\nPATH\nTIMESTAMP\nNONCE\nSHA256(body) — and the server recomputes it from the request it actually received. Method and path bind the signature to one endpoint; timestamp and nonce kill replays; the body hash kills tampering. Create server.js:
import express from 'express';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import { readFileSync } from 'node:fs';
const keys = JSON.parse(readFileSync('./keys.json', 'utf8'));
const app = express();
app.use(express.json({
// HMAC must cover the exact bytes the client sent, not re-serialized JSON
verify: (req, _res, buf) => { req.rawBody = buf; },
}));
const MAX_SKEW_MS = 5 * 60 * 1000;
const seenNonces = new Map(); // nonce -> expiry (use Redis SET NX EX in production)
setInterval(() => {
const now = Date.now();
for (const [nonce, exp] of seenNonces) if (exp < now) seenNonces.delete(nonce);
}, 60_000).unref();
function verifySignature(req, res, next) {
const keyId = req.get('X-Key-Id');
const timestamp = req.get('X-Timestamp');
const nonce = req.get('X-Nonce');
const signature = req.get('X-Signature');
if (!keyId || !timestamp || !nonce || !signature) {
return res.status(401).json({ error: 'missing auth headers' });
}
const key = keys[keyId];
if (!key) return res.status(401).json({ error: 'unknown key id' });
const skew = Math.abs(Date.now() - Number(timestamp) * 1000);
if (!Number.isFinite(skew) || skew > MAX_SKEW_MS) {
return res.status(401).json({ error: 'timestamp outside allowed window' });
}
if (seenNonces.has(nonce)) {
return res.status(401).json({ error: 'nonce already used' });
}
const bodyHash = createHash('sha256')
.update(req.rawBody ?? Buffer.alloc(0))
.digest('hex');
const canonical = [req.method, req.originalUrl, timestamp, nonce, bodyHash].join('\n');
const expected = createHmac('sha256', key.secret).update(canonical).digest();
const provided = Buffer.from(signature, 'hex');
// length check first: timingSafeEqual throws on unequal-length buffers
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
return res.status(401).json({ error: 'bad signature' });
}
seenNonces.set(nonce, Date.now() + MAX_SKEW_MS);
req.client = { keyId, scopes: key.scopes };
next();
}
const requireScope = (scope) => (req, res, next) =>
req.client.scopes.includes(scope)
? next()
: res.status(403).json({ error: `missing scope: ${scope}` });
app.get('/v1/reports', verifySignature, requireScope('reports:read'), (req, res) => {
res.json({ reports: [{ id: 1, name: 'Q2 revenue' }] });
});
app.post('/v1/reports', verifySignature, requireScope('reports:write'), (req, res) => {
res.status(201).json({ created: req.body.name });
});
app.listen(3000, () => console.log('API listening on :3000'));
Two details matter. express.json's verify callback hands you the raw body buffer before parsing — signing JSON.stringify(req.body) instead would break on any whitespace or key-order difference. And timingSafeEqual (not ===) prevents attackers from recovering a valid signature byte-by-byte through response-time differences.
The in-memory nonce Map only needs to remember nonces for the 5-minute timestamp window — anything older fails the skew check anyway. It won't survive restarts or span multiple instances; that's what the Redis swap is for.
4. Write a signing client
Create client.js — this is the code you'd ship in your API's SDK:
import { createHash, createHmac, randomBytes } from 'node:crypto';
const { KEY_ID: keyId, KEY_SECRET: secret } = process.env;
const method = process.argv[2] ?? 'GET';
const path = process.argv[3] ?? '/v1/reports';
const body = process.argv[4] ?? '';
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomBytes(16).toString('hex');
const bodyHash = createHash('sha256').update(body).digest('hex');
const canonical = [method, path, timestamp, nonce, bodyHash].join('\n');
const signature = createHmac('sha256', secret).update(canonical).digest('hex');
const res = await fetch(`http://localhost:3000${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Key-Id': keyId,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature,
},
body: body || undefined,
});
console.log(res.status, JSON.stringify(await res.json()));
Verify it works
Start the server (node server.js), then in a second terminal export the read-only key from step 2 and run the four checks:
export KEY_ID=ak_1187ca72245faef2 # your values from issue-key.js
export KEY_SECRET=814ebc7bc9f5de84...
node client.js GET /v1/reports
node client.js POST /v1/reports '{"name":"Q3 forecast"}'
curl -s -w ' HTTP %{http_code}\n' http://localhost:3000/v1/reports
KEY_SECRET=deadbeef node client.js GET /v1/reports
Expected output, in order:
200 {"reports":[{"id":1,"name":"Q2 revenue"}]}
403 {"error":"missing scope: reports:write"}
{"error":"missing auth headers"} HTTP 401
401 {"error":"bad signature"}
Switch KEY_ID/KEY_SECRET to the read-write key and the POST returns 201 {"created":"Q3 forecast"}. To confirm replay protection, resend a request with identical headers (copy them from the client, or re-fetch with the same nonce): the first attempt returns 200, the second returns 401 {"error":"nonce already used"}.
Troubleshooting
SyntaxError: Cannot use import statement outside a module— you skippednpm pkg set type=module. Run it (or rename files to.mjs).TypeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length—timingSafeEqualthrows, not returns false, when a client sends a truncated or non-hex signature. Keep theprovided.length !== expected.lengthguard before the call; it leaks nothing, since signature length isn't secret.401 {"error":"bad signature"}on POST while GET works — the canonical strings disagree, almost always on the body hash or path. Sign the raw bytes (req.rawBodyfrom theverifyhook), never re-serialized JSON, and make sure the client signs the full path including the query string, since the server usesreq.originalUrl.401 {"error":"timestamp outside allowed window"}on every request — the client sent milliseconds (Date.now()) where the server expects Unix seconds, which lands ~55 years in the future. UseMath.floor(Date.now() / 1000); if it's intermittent, fix clock drift with NTP.
Next steps
- Move nonce state to Redis with
SET nonce 1 NX EX 300— one atomic command replaces theMapand works across instances. - Encrypt stored secrets with a KMS, and add key rotation: allow two active secrets per key ID so clients can roll over without downtime.
- Compare your scheme against AWS Signature Version 4, which adds credential scoping and signed headers — the natural next layer if you need to sign header values too.
- If clients are third parties you don't ship an SDK to, consider the emerging IETF HTTP Message Signatures standard (RFC 9421) instead of a bespoke header format.
Sources & further reading
- body-parser middleware (express.json verify option) — expressjs.com
- Node.js Crypto module documentation — nodejs.org
- Node.js Releases (LTS schedule) — nodejs.org
- Express 5.1.0: Now the Default on npm — expressjs.com
- AWS Signature Version 4 for API requests — docs.aws.amazon.com
- RFC 9421: HTTP Message Signatures — datatracker.ietf.org
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 0
No comments yet
Be the first to weigh in.