Ask Anvil

Answers to questions about automating PDFs, e-signatures, Webforms, and other paperwork problems.
E-signatures
Categories

How do I generate secure, expiring, single-use signing links for a custom e-signature flow?

Sign a small payload with an HMAC, put the result in the link, and check the signature, the expiry, and a used-token record before showing the signing page. Node's built-in crypto module is enough, no libraries needed.

const crypto = require("node:crypto");

const SECRET = Buffer.from(process.env.SIGNING_LINK_SECRET, "hex"); // 32 random bytes
const used = new Set(); // production: a table with a unique constraint on jti

const mac = (body) => crypto.createHmac("sha256", SECRET).update(body).digest();

function createSigningToken(signerId, docId, ttlSeconds = 3600) {
  const payload = {
    sid: signerId,
    doc: docId,
    jti: crypto.randomUUID(),
    exp: Math.floor(Date.now() / 1000) + ttlSeconds,
  };
  const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
  return `${body}.${mac(body).toString("base64url")}`;
}

function verifySigningToken(token) {
  const [body, sig] = String(token).split(".");
  if (!body || !sig) throw new Error("malformed token");
  const expected = mac(body);
  const given = Buffer.from(sig, "base64url");
  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
    throw new Error("bad signature");
  }
  const payload = JSON.parse(Buffer.from(body, "base64url").toString());
  if (payload.exp < Math.floor(Date.now() / 1000)) throw new Error("expired");
  if (used.has(payload.jti)) throw new Error("already used");
  return payload;
}

// Call this only after the signature has actually been captured.
function markUsed(payload) {
  used.add(payload.jti);
}

The token is two base64url parts: the JSON payload (signer, document, a random token ID, expiry) and its HMAC-SHA256 tag. Anyone can read the payload, but nobody can change it without the secret, so the server can trust sid, doc, and exp without a database lookup. The length check before timingSafeEqual is required because it throws when the two buffers differ in length. The base64url encoding (no padding, URL-safe characters) needs Node 15.7 or 14.18 and later.

Caveats

Mark the token used only after the signature is captured, not when the page opens. Otherwise a signer who reloads the page or loses their connection is locked out. In production, replace the in-memory Set with a table that has a unique constraint on jti, so two simultaneous requests cannot both succeed.

A link is a bearer credential: anyone who has it can sign. Serve the signing page with a Referrer-Policy: no-referrer header so the token in the URL is not passed to other sites in the Referer header, and for high-stakes documents add a second check such as an emailed one-time code.

Back to All Questions

The fastest way to build software for documents

Anvil Document SDK is a comprehensive toolbox for product teams launching document flows where PDF filling, signing, and complex conditional scenarios are necessary.
Explore Anvil
Anvil Webforms
Marketing Mode