Skip to main content
jg-packages

Search

    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:

    ts
    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:

    ts
    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:

    ts
    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.