On this page
Patterns for Node.js services that verify webhooks, keep tokens out of their database, and sign the values they hand to clients.
Verifying a GitHub webhook
Use this in the route that receives GitHub’s webhook deliveries. GitHub signs the request body with the webhook’s secret and sends the signature in X-Hub-Signature-256, prefixed with sha256=. Verify it over the body’s exact bytes, before parsing it, because JSON parsing and re-serializing can change the bytes:
import { verifyHmac } from 'jg-crypto';
// the request as your framework exposes it, with the body kept as raw bytes
type IWebhookRequest = {
headers: Record<string, string | undefined>;
rawBody: Buffer;
};
// the secret set on the webhook in GitHub, read from the app's configuration
declare const webhookSecret: string;
// the outcome of a delivery, as a status for the response and the payload when it's authentic
export const receiveGitHubWebhook = (
request: IWebhookRequest,
): { status: number; payload?: unknown } => {
const header = request.headers['x-hub-signature-256'] ?? '';
const signature = header.startsWith('sha256=') ? header.slice('sha256='.length) : '';
if (!verifyHmac(request.rawBody, signature, webhookSecret)) {
return { status: 401 };
}
return { status: 202, payload: JSON.parse(request.rawBody.toString('utf8')) };
};A missing or malformed header yields an empty signature, which verifyHmac rejects like a wrong one, so the route needs no separate checks. The comparison runs in constant time, so an attacker can’t learn the signature by timing the responses. Without a domain, the signature is the plain HMAC-SHA256 GitHub computes.
Storing a token’s hash, never the token
Use this for any secret the app hands out and later checks, such as a password-reset link, an API key, or an invitation. The database keeps only the token’s SHA-256, so someone who reads the table can’t use what they find:
import { generateRandomBytes, hashValue } from 'jg-crypto';
// the app's storage for reset tokens, keyed by the token's hash
type IResetTokenStore = {
save: (record: { tokenHash: string; userId: string; expiresAt: Date }) => Promise<void>;
findValid: (tokenHash: string) => Promise<{ userId: string } | undefined>;
};
declare const store: IResetTokenStore;
// creates a token that expires in 30 minutes, and returns it for the email link
export const createResetToken = async (userId: string): Promise<string> => {
const token = generateRandomBytes(32);
await store.save({
tokenHash: hashValue(token),
userId,
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
});
return token;
};
// finds who a token from a link belongs to, by hashing it the same way
export const findResetTokenOwner = async (token: string): Promise<string | undefined> =>
(await store.findValid(hashValue(token)))?.userId;A 32-byte token has 256 bits of entropy, so its plain SHA-256 is enough: there’s nothing to guess. Passwords are different, because people choose them, and need a slow, salted hash such as Node.js’s scrypt instead.
Signing values you hand to clients
Use this when a client holds a value the server must trust when it comes back, such as a pagination cursor or a resumable job’s checkpoint. Give each kind of value its own domain, so a cursor can’t be replayed as a checkpoint even though both are signed with the same secret:
import { signHmac, verifyHmac } from 'jg-crypto';
// the signing secret, read from the app's configuration
declare const signingSecret: string;
// a cursor the client sends back as is: its JSON and the signature over it
export const signCursor = (cursor: { lastId: string }): string => {
const json = JSON.stringify(cursor);
return `${Buffer.from(json).toString('base64url')}.${signHmac(json, signingSecret, { domain: 'cursor' })}`;
};
// the cursor, or undefined when it was changed, or signed for another purpose
export const readCursor = (value: string): { lastId: string } | undefined => {
const [encoded = '', signature = ''] = value.split('.');
const json = Buffer.from(encoded, 'base64url').toString('utf8');
if (!verifyHmac(json, signature, signingSecret, { domain: 'cursor' })) {
return undefined;
}
return JSON.parse(json) as { lastId: string };
};The signature proves the server made the value; it doesn’t hide it. Anyone can decode the cursor’s JSON, so put nothing in it the client shouldn’t see.