Quickstart
Four tasks with unsecure: create record IDs, verify a webhook, check a password, and read a JSON body.
Each task below is one small function you can paste into a server and call from a route handler. Each ends with a link to the page that tells the rest of its story. If you wonder why a bad signature gets false rather than an exception, Trusted and untrusted input explains it.
#Before you start
You need three things in place:
unsecureinstalled, as described in Installation- A runtime with the Fetch API's
RequestandResponse: Node.js 22.13 or later (24 or later on the 24 line), Bun, Deno or Cloudflare Workers - For the webhook task, the signing secret from your provider in the
GITHUB_WEBHOOK_SECRETenvironment variable
#Create record IDs
A UUIDv7 begins with the time it was made, like a receipt number with the date printed first. That's why IDs sort by the millisecond they were created in. When an ID comes back to you in a URL, check it before it goes anywhere near a query:
import { isUUIDv7, uuidv7 } from "unsecure/uuid";
interface Invoice {
id: string;
customer: string;
total: number;
}
export function createInvoice(customer: string, total: number): Invoice {
return { id: uuidv7(), customer, total };
}
/** The id from `/invoices?id=…`, or `null` when it is missing or malformed. */
export function invoiceIdFrom(url: URL): string | null {
const id = url.searchParams.get("id");
return isUUIDv7(id) ? id : null;
}isUUIDv7 returns false for null, for a UUIDv4 and for any other text, so a missing or malformed ID turns into null without a try. It only looks at the shape, though: a well-formed ID you never issued still passes, and your query finds no row for it. UUID covers generators, timestamps and when a v4 is the better pick.
#Verify a webhook signature
A webhook signature is a stamp only someone with the shared secret can make. GitHub stamps each delivery it sends; you stamp the raw body that arrived and compare the two:
import { hmacVerify, importHmacKey } from "unsecure/hmac";
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) throw new Error("GITHUB_WEBHOOK_SECRET is not set");
const key = await importHmacKey(secret);
export async function receiveWebhook(request: Request): Promise<Response> {
const body = await request.arrayBuffer();
const header = request.headers.get("x-hub-signature-256");
const signature = header?.startsWith("sha256=") ? header.slice(7) : null;
if (!(await hmacVerify(key, body, signature))) {
return new Response("Invalid signature", { status: 401 });
}
// Parse `body` and handle the event.
}The handler stays safe because of how it treats the body, the header and the secret:
- Raw bytes: you read the body once as an
ArrayBuffer, so your stamp covers exactly the bytes GitHub sent, not a re-encoded copy. - Missing header:
hmacVerifyanswersfalsefornullor a garbled signature, and it compares in constant time, so the response time gives nothing away. - Missing secret: the module throws at start-up, before the first request, and imports the key once for every request after it.
Other providers format their signatures differently; HMAC covers those and reusing an imported key.
#Hash and check a password
You never store the password itself, only the string argon2Hash gives back. It packs the salt and the cost settings in with the hash, so one text column holds everything:
import { argon2Hash, argon2Verify } from "unsecure/argon2";
/** At sign-up: the string to store in the account's password column. */
export async function hashPassword(password: string): Promise<string> {
return argon2Hash(password);
}
/** At login: `true` only when `attempt` is the password `stored` was made from. */
export async function checkPassword(stored: string, attempt: unknown): Promise<boolean> {
// A form field can be missing or an array. Refuse it here, because
// `argon2Verify` throws for anything that is not text.
if (typeof attempt !== "string") return false;
return argon2Verify(stored, attempt);
}Each call blocks the thread it runs on, and that's the point: the same cost makes every guess slow for someone who stole your table. The example calls it straight from a route handler to stay short; Store passwords gives the actual cost and when to move it to a worker. argon2Verify throws INVALID_TYPE for a password that isn't text, which is why checkPassword turns one away first. A real login form needs a little more, and Store passwords adds it: a pepper, rehashing, and the same answer time for unknown emails.
#Read a JSON body
JSON can carry a key literally called __proto__. Merge that object into another one, and it can change how every object in your process behaves. safeJsonParse strips those keys out and reports text that isn't JSON as MALFORMED:
import { UnsecureError } from "unsecure/errors";
import { safeJsonParse } from "unsecure/sanitize";
export async function createComment(request: Request): Promise<Response> {
let comment: unknown;
try {
comment = safeJsonParse(await request.text());
} catch (error) {
if (error instanceof UnsecureError && error.code === "MALFORMED") {
return Response.json({ error: "The body is not valid JSON." }, { status: 400 });
}
throw error;
}
// Check `comment` against your schema, then store it.
}What comes back is safe from prototype pollution, but it's still whatever the client chose to send, so check its shape before you store it. If your framework parses bodies for you, Sanitize shows how to clean them afterwards.
#When it fails
Here's what each task does when something goes wrong:
| Situation | What happens |
|---|---|
| Webhook without the signature header, or with a wrong one | hmacVerify returns false and the handler answers 401. |
GITHUB_WEBHOOK_SECRET unset or empty | The module throws when it loads, so the server fails to start. |
| Stored password column holds something other than an Argon2 hash | argon2Verify throws MALFORMED. Fix the data; it isn't a wrong password. |
| Request body isn't valid JSON | safeJsonParse throws MALFORMED and the handler answers 400. Any other error is rethrown. |
#Going further
Once these four work, these pages take you further: