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