Security

API key security for Node APIs: prefixes, hashing, scopes and rotation

Key format, SHA-256 storage, Express verification with scopes, zero-downtime rotation and a leak checklist.

API key security, from format to rotation. Cover graphic for a guide to generating, hashing, scoping and rotating API keys in Node.js.

A customer emails to say they pushed their API key to a public GitHub repo an hour ago. Can you tell whether anyone used it? Can they rotate it without downtime? And if your database leaked tomorrow, would every customer’s key leak with it? API key security comes down to a handful of design decisions that are cheap on day one and painful to retrofit.

The direct answer: generate keys with at least 256 bits of randomness, give them a recognizable prefix so leaks are easy to spot, show the key once, store only a SHA-256 hash, look keys up by a separate public ID, attach scopes and an optional expiry, allow two active keys per account so rotation has no downtime, and never log the key itself. Here is how to do each in Node.

What a good API key looks like

The format we will build:

text
atk_live_5d0de6ca2ad7_fYF7sqANvWIOd9-JHGS05Snc89N4M13XeBFhunWSSjU_1aqdw5g
└─┬┘ └┬─┘ └────┬────┘ └───────────────────┬───────────────────┘ └──┬──┘
prefix env   key ID          secret (32 random bytes)          checksum
  • Prefix (atk_): makes your keys recognizable to people, grep and secret scanners. GitHub moved to prefixed tokens like ghp_ in 2021 for exactly this reason, and Stripe’s sk_live_ / sk_test_ keys are the familiar example.
  • Environment (live / test): a test key in production code is a bug you can catch at a glance.
  • Key ID: a short, non-secret identifier used to find the row and to show the key in your dashboard.
  • Secret: 32 bytes from crypto.randomBytes, base64url encoded. This is the only part that needs protecting.
  • Checksum: a CRC32 of the rest, so you can reject typos and truncated keys without a database query. GitHub’s token format includes a checksum for the same reason.

How to generate API keys in Node.js

js
// api-keys.js
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
import { crc32 } from 'node:zlib'; // Node 20.15+ / 22.2+

const PREFIX = 'atk';

export function generateApiKey(env = 'live') {
  const id = randomBytes(6).toString('hex');
  const secret = randomBytes(32).toString('base64url');
  const body = `${PREFIX}_${env}_${id}_${secret}`;
  const checksum = crc32(body).toString(36).padStart(7, '0');
  return {
    key: `${body}_${checksum}`, // show once, never store
    id,
    hash: createHash('sha256').update(secret).digest('hex'),
  };
}

export function parseApiKey(key) {
  const m = /^atk_(live|test)_([0-9a-f]{12})_([A-Za-z0-9_-]{43})_([0-9a-z]{7})$/.exec(key ?? '');
  if (!m) return null;
  const [, env, id, secret, checksum] = m;
  const expected = crc32(`${PREFIX}_${env}_${id}_${secret}`).toString(36).padStart(7, '0');
  return checksum === expected ? { env, id, secret } : null;
}

export function secretMatches(secret, storedHashHex) {
  const a = createHash('sha256').update(secret).digest();
  const b = Buffer.from(storedHashHex, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Why hash API keys with SHA-256, not bcrypt

Passwords need slow hashes (bcrypt, scrypt, Argon2) because people choose guessable passwords. A 256-bit random secret cannot be guessed, so a fast hash such as SHA-256 is enough: a stolen hash is useless without the secret. A slow hash would add tens of milliseconds to every authenticated request for no security gain.

Storing only the hash means a database dump, a backup in the wrong bucket or a support engineer’s SQL query does not expose working keys. It also means you cannot show the key again; users must copy it when it is created.

A minimal table:

sql
CREATE TABLE api_keys (
  id            text PRIMARY KEY,          -- the public key ID
  account_id    bigint NOT NULL REFERENCES accounts(id),
  secret_hash   text NOT NULL,             -- sha256 hex
  env           text NOT NULL,             -- 'live' | 'test'
  scopes        text[] NOT NULL DEFAULT '{}',
  name          text,                      -- "CI deploy key"
  created_at    timestamptz NOT NULL DEFAULT now(),
  expires_at    timestamptz,
  last_used_at  timestamptz,
  revoked_at    timestamptz
);

How to verify API keys in Express

js
import { parseApiKey, secretMatches } from './api-keys.js';

export async function apiKeyAuth(req, res, next) {
  const parsed = parseApiKey(req.get('x-api-key'));
  if (!parsed) return res.status(401).json({ error: 'invalid_api_key' });

  const row = await db.apiKeys.findById(parsed.id);
  const valid =
    row &&
    !row.revoked_at &&
    (!row.expires_at || row.expires_at > new Date()) &&
    row.env === parsed.env &&
    secretMatches(parsed.secret, row.secret_hash);

  if (!valid) return res.status(401).json({ error: 'invalid_api_key' });

  req.apiKey = { id: row.id, accountId: row.account_id, scopes: new Set(row.scopes) };
  db.apiKeys.touchLastUsed(row.id).catch(() => {}); // throttle this in production
  next();
}

export const requireScope = (scope) => (req, res, next) =>
  req.apiKey?.scopes.has(scope) ? next() : res.status(403).json({ error: 'insufficient_scope' });

// usage
app.get('/v1/orders', apiKeyAuth, requireScope('orders:read'), listOrders);

Return the same 401 for “malformed”, “not found”, “revoked” and “wrong secret”, so callers cannot probe which keys exist. Use 403 only when the key is valid but lacks the scope.

Scopes: least privilege for API keys

Most API keys end up in CI systems, Zapier, cron jobs and contractors’ laptops. Scopes limit the damage when one leaks. Start simple: resource:read and resource:write per resource, plus a “full access” option for users who insist. Default new keys to read-only and make write access a deliberate choice.

How to rotate API keys without downtime

Rotation fails when an account can have only one key. Allow at least two active keys, then:

  1. Create the new key and give it to the client.
  2. The client deploys it.
  3. Watch last_used_at on the old key until it stops changing.
  4. Revoke the old key.

Optional expiry (90 days, say) forces this habit for high-risk keys. Showing last_used_at and the key name in your dashboard turns “which of these keys is safe to delete?” into an easy question.

When a key leaks, the order is: revoke immediately, issue a replacement, then check what the leaked key did. That last step needs request logs tied to the key ID, which is a good reason to keep them.

Do not let API keys leak through your own logs

The most common leak is not GitHub; it is your logs. Mask X-Api-Key and Authorization in request logging (see how to redact sensitive data in logs), log the key ID instead of the key, and never put keys in URLs, where proxies and analytics record them. Pair key auth with rate limiting per API key so a leaked key cannot be used at unlimited speed.

API key security checklist

  • [ ] 32 random bytes per key; prefix, environment and key ID in the format.
  • [ ] Shown once; only the SHA-256 hash stored.
  • [ ] Constant-time comparison; one generic 401.
  • [ ] Scopes, with read-only as the default.
  • [ ] Two active keys per account; last_used_at visible.
  • [ ] Keys masked in all logs; key ID logged instead.
  • [ ] Rate limits per key.

A solo developer can ship the first four in an afternoon, before the first customer gets a key. A team of ten should also write the leak runbook (revoke, replace, review usage) and consider registering the prefix with GitHub’s secret scanning partner program so leaked keys are reported to you.

FAQ

Should I hash API keys with bcrypt?

No, not if keys are long random values. bcrypt protects low-entropy passwords from guessing; a 256-bit random secret cannot be guessed, so SHA-256 is enough and keeps authentication fast.

Should API keys expire?

Make expiry optional and encourage it for high-risk keys. Forced short expiry on every key mostly produces outages when someone forgets to rotate; supporting two active keys and showing last use gets you most of the benefit.

Where should clients send the API key?

In a header such as Authorization: Bearer ... or X-Api-Key, never in the query string. URLs end up in access logs, browser history and analytics tools.

Doing this with ApiTraffic

If you would rather not build masking, per-key limits and usage tracking yourself, ApiTraffic covers the parts around your key auth. Authorization, Cookie and X-Api-Key are masked by default in the request log. Firewall rate limits can be keyed on a header, so each API key gets its own limit, with 429 and Retry-After for callers over it.

Tag each request with the account, apitraffic.tag('accountId', String(req.apiKey.accountId)), and the “did anyone use the leaked key?” question becomes a search: tag:accountId == "1042" AND status_code >= 400, or the same tag over the last day.

Start free and see every request each API key makes: apitraffic.io

Sources: GitHub: Behind GitHub’s new authentication token formats (April 2021); Node.js crypto: randomBytes and timingSafeEqual; Node.js zlib.crc32; OWASP API Security Top 10 2023: API2 Broken Authentication; GitHub secret scanning partner program.

Keep reading