@lacspace/signed-url
v1.2.0
Published
HMAC-signed, expiring URLs & tokens over Web Crypto — secure download links, magic-login links, unsubscribe links and one-time-action tokens. Tamper-proof, timing-safe, zero-config. Isomorphic (Node, edge, browser).
Maintainers
Readme
@lacspace/signed-url
HMAC-signed, expiring URLs & tokens over Web Crypto — magic links, secure downloads, unsubscribe links, one-time actions.
Two things every backend re-implements badly: signed tokens (magic-login links, email verification, unsubscribe, one-time actions) and signed URLs (expiring, tamper-proof download / image-proxy links — self-hosted, S3-presigned-style). This does both, correctly, in a few bytes.
New in 1.1.0 — an additive secure layer (
signSecure/verifySecure/signSecureUrl/verifySecureUrl) adding key rotation (named secrets), binding (method / IP / path-prefix), a single-use nonce +consumeNoncechecker, aclockToleranceleeway, and signed claims. Every existing helper is unchanged and cross-compatible with the new one.
- 🔐 Tamper-proof — any change to the data or URL breaks the signature
- ⏱️ Expiring —
expiresIn/expiresAt, with clock-skew tolerance - 🛡️ Timing-safe verification (built on
@lacspace/crypto, Web Crypto — never hand-rolled) - 🪄
magicLink()/readMagicLink()helpers for passwordless auth - ⚡ Isomorphic — Node, edge runtimes & browsers · 📦 ESM + CJS · fully typed
What's new in 1.2.0 — security fix, upgrade recommended
- Signed URLs could be extended with extra parameters. The signature covered
the query with its values decoded and joined as-is, so
?name=x%26role%3Dadmin(one parameter) and?name=x&role=admin(two) signed identically. If a user could get a value of their choosing into a signed link (a filename, a search term, a return path), they could turn it into parameters of their choosing and the signature still verified. AffectssignUrl/verifyUrlandsignSecureUrl/verifySecureUrl; tokens and magic links were not affected. Keys and values are now percent-encoded before signing, so each URL has exactly one signed form. - Existing links: a link whose parameter values only use letters, digits and
-_.!~*'()signs the same as before and keeps verifying. A link whose values contain spaces, non-ASCII text or other symbols stops verifying. PassacceptLegacySignatures: truetoverifyUrl/verifySecureUrluntil those links expire. That flag also re-opens the hole above, so remove it afterwards.
Install
npm install @lacspace/signed-url # or pnpm add / yarn add / bun addSigned tokens
import { sign, verify } from "@lacspace/signed-url";
// e.g. a password-reset link
const token = await sign({ userId: 42, action: "reset" }, {
secret: process.env.LINK_SECRET!,
expiresIn: 3600, // seconds
});
const r = await verify<{ userId: number; action: string }>(token, { secret: process.env.LINK_SECRET! });
if (r.valid) {
grantReset(r.data.userId);
} else {
// r.reason → "malformed" | "bad-signature" | "expired"
}verify() never throws — it always returns { valid, data?, reason?, expiresAt? }.
Magic links (passwordless auth)
import { magicLink, readMagicLink } from "@lacspace/signed-url";
// send this link by email
const link = await magicLink("https://app.me/auth/callback", { email }, {
secret, expiresIn: 900,
});
// in your callback route
const r = await readMagicLink<{ email: string }>(request.url, { secret });
if (r.valid) signIn(r.data.email);Signed URLs (expiring, tamper-proof download links)
import { signUrl, verifyUrl } from "@lacspace/signed-url";
// hand out a link that stops working in 5 minutes
const link = await signUrl("https://cdn.me/files/report.pdf?uid=42", {
secret, expiresIn: 300,
});
// → https://cdn.me/files/report.pdf?uid=42&exp=1699999999&sig=AbC…
// in the route that serves the file
const r = await verifyUrl(request.url, { secret });
if (!r.valid) return new Response("Link expired or invalid", { status: 403 });Query-param order is normalised, so the link verifies no matter how params get reordered — and changing the path or any param invalidates it.
Secure layer — rotation, binding, nonce, claims (new in 1.1.0)
signSecure / verifySecure are a drop-in superset of sign / verify (same token
format, cross-compatible), and signSecureUrl / verifySecureUrl do the same for URLs.
Everything below is optional — omit it all and you get the classic behaviour.
import { signSecure, verifySecure, consumeNonce } from "@lacspace/signed-url";
// Key rotation: sign with the active key, verify against the whole set.
const token = await signSecure({ userId: 42 }, {
keys: { "2024": OLD_SECRET, "2025": NEW_SECRET },
keyId: "2025", // stamped into the signature
bind: { method: "POST", ip: req.ip, pathPrefix: "/admin/" }, // constraints
nonce: true, // random single-use nonce
claims: { role: "owner" }, // tamper-proof metadata
expiresIn: 900,
});
const r = await verifySecure(token, {
keys: { "2024": OLD_SECRET, "2025": NEW_SECRET }, // rotate secrets freely
context: { method: req.method, ip: req.ip, path: url.pathname }, // enforced
clockTolerance: 30, // 30s leeway on expiry
});
if (r.valid) {
// r.data, r.claims, r.nonce, r.keyId
// Enforce one-time use with YOUR store (library owns no storage):
const prior = await store.get(r.nonce!); // number | undefined
const c = consumeNonce(prior, { maxUses: 1 });
if (!c.ok) return reject("already used");
await store.set(r.nonce!, c.uses);
}
// r.reason may also be "unknown-key" (wrong key set) or "binding" (constraint mismatch).API
| Function | Description |
| --- | --- |
| sign(data, { secret, expiresIn?, expiresAt?, algorithm? }) | data → compact signed token |
| verify(token, { secret, clockToleranceSec? }) | { valid, data?, reason?, expiresAt? } |
| isValid(token, opts) | boolean convenience |
| signUrl(url, { secret, expiresIn? }) | append exp + sig params |
| verifyUrl(url, { secret }) | verify signature + expiry |
| magicLink(baseUrl, data, opts) | base URL + signed token param |
| readMagicLink(url, opts) | verify the embedded token |
| signSecure(data, { secret? \| keys?+keyId?, bind?, nonce?, claims?, ...expiry }) | token + rotation / binding / nonce / claims |
| verifySecure(token, { secret? \| keys?, context?, clockTolerance?, ... }) | { valid, data?, claims?, nonce?, keyId?, reason?, expiresAt? } |
| signSecureUrl(url, opts) / verifySecureUrl(url, opts) | the same, for URLs |
| generateNonce(bytes?) | random hex nonce (default 16 bytes) |
| consumeNonce(previousUses?, { maxUses? }) | pure single-use / max-N checker → { ok, uses, remaining } |
reason is one of "malformed" | "bad-signature" | "expired" (classic) plus "unknown-key" | "binding" (secure layer). Default algorithm is SHA-256 (SHA-384 / SHA-512 also supported).
Licensing
This package is free under the Lacspace Free Licence — permissive freedoms. Use it in personal and commercial projects at no cost; just keep the notice.
Not every Lacspace package is free. We also offer Commercial (paid), Client-specific, and Private (proprietary) packages under separate terms. See the full Lacspace Licence Centre.
The Lacspace Developer Platform
@lacspace/signed-url is part of 80+ zero-dependency, isomorphic TypeScript packages — one standard library for the modern web. Explore the ecosystem:
- 📦 This package, documented — https://developer.lacspace.com/packages/signed-url
- 🗂️ All 80+ packages — https://developer.lacspace.com/packages
- 🧭 Developer handbook — guides & runnable recipes — https://developer.lacspace.com/handbook
- 🧪 Live playground — run any package in your browser — https://developer.lacspace.com/playground
- 🖥️ Finished app templates — https://templates.lacspace.com
- 🚀 Scaffold a full app —
npm create lacspace-app@latest
Free under the Lacspace Free Licence — a permissive, free-to-use licence.
