Skip to content
Security Advanced Tutorial

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.

Emeka Okafor
Emeka Okafor
Security Editor · Jul 23, 2026 · 6 min read
Issue HMAC-Signed, Scoped API Keys for a Public REST API

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:crypto module and global fetch). Verified against Node 24 (Active LTS) and Express 5.2.1, the current npm default.
  • npm and curl on 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 skipped npm 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 lengthtimingSafeEqual throws, not returns false, when a client sends a truncated or non-hex signature. Keep the provided.length !== expected.length guard 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.rawBody from the verify hook), never re-serialized JSON, and make sure the client signs the full path including the query string, since the server uses req.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. Use Math.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 the Map and 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

  1. body-parser middleware (express.json verify option) — expressjs.com
  2. Node.js Crypto module documentation — nodejs.org
  3. Node.js Releases (LTS schedule) — nodejs.org
  4. Express 5.1.0: Now the Default on npm — expressjs.com
  5. AWS Signature Version 4 for API requests — docs.aws.amazon.com
  6. RFC 9421: HTTP Message Signatures — datatracker.ietf.org
Emeka Okafor
Written by
Emeka Okafor · Security Editor

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading