SearchCtrlK

  • ↑↓navigate
  • ↵open
  • escclose

Can’t find it? Contact us

Identifiers and secrets

Which generator mints a record id, a random identifier and a secret, and why a UUID never stands in for a secret.

After reading this guide, you will know

  • When a value gets a time-ordered UUIDv7, a random UUIDv4 or a token from randomToken
  • Why a UUID is an identifier and never a secret
  • How to store a token as a digest and test what each generator promises

An app mints three kinds of value that all look like random strings: the primary key of a row, an identifier that only has to be unique, and a secret that grants whoever holds it access to something. Each has its own generator, picked by what the value does once it exists.

Terminal
npm add @sdxc/crypto @sdxc/data-model @sdxc/jwt @sdxc/result @sdxc/uuid

Pick the generator by what the value does

The valueMint it withBecause
A record's primary keygenerateUUID from @sdxc/uuid/v7it sorts by creation, so new rows append to the index and an id pages a list
A JWT jti, a request or correlation id, a job id, a test fixture's idgenerateUUID from @sdxc/uuid/v4it is unique and carries no creation time or order
A session or API token, an invitation or reset token, OAuth state, an authorization coderandomToken from @sdxc/crypto256 random bits in a URL-safe string, with a prefix a scanner can flag

Both UUID entry points export the same generateUUID(), returning the branded UUID type from @sdxc/uuid, so the import path is the whole decision. randomToken returns a plain string.

Give every record a time-ordered id

A model's generateId mints the key of every row inserted without one. Pass the v7 generator:

app/models/teams.ts
import { createModel } from "@sdxc/data-model";
import { generateUUID } from "@sdxc/uuid/v7";

import { teams } from "~/database/schema";

export const Teams = createModel(teams, { generateId: generateUUID });

The timestamp comes first in a v7 value, so string order is creation order: each insert lands at the end of the primary key's index, and the last id of a page is a complete cursor for the next one. Anyone holding a v7 id can read when it was minted, so an id you show outside the system whose creation time should stay private comes from @sdxc/uuid/v4 instead.

Mint a random id for everything else

An identifier that names an event, a token or a request only has to be unique. A v4 value has no order and no timestamp, so it says nothing beyond itself. A token's jti, which a revocation list stores, is one:

app/services/access-tokens.ts
import { JWT } from "@sdxc/jwt";
import { generateUUID } from "@sdxc/uuid/v4";

export function accessToken(userId: string) {
	return new JWT({
		sub: userId,
		aud: "api",
		iat: "0s",
		exp: "15m",
		jti: generateUUID(),
	});
}

Mint secrets with randomToken

A secret is a value whose holder is let in: an invitation link, a password reset, an API key, the OAuth state a callback checks. randomToken draws 256 random bits and encodes them as 43 URL-safe characters; a prefix is joined with _, so a leaked key is recognizable by kind.

An invitation pairs both kinds of value: the row gets a v7 id, and the link gets a token. Store the token's SHA-256 digest, so a reader of the table finds digests rather than working links:

app/services/invitations.ts
import type { Result } from "@sdxc/result";
import type { UUID } from "@sdxc/uuid";

import { Hex, randomToken, sha256 } from "@sdxc/crypto";
import { isFailure, success } from "@sdxc/result";
import { generateUUID } from "@sdxc/uuid/v7";

export interface Invitation {
	id: UUID;
	teamId: UUID;
	tokenHash: string;
}

export interface IssuedInvitation {
	invitation: Invitation;
	token: string;
}

export async function hashInvitationToken(
	token: string,
): Promise<Result<string, Error>> {
	let digest = await sha256(token);
	if (isFailure(digest)) return digest;
	return success(Hex.encode(digest.data));
}

export async function issueInvitation(
	teamId: UUID,
): Promise<Result<IssuedInvitation, Error>> {
	let token = randomToken({ prefix: "inv" });
	let tokenHash = await hashInvitationToken(token);
	if (isFailure(tokenHash)) return tokenHash;

	let invitation = { id: generateUUID(), teamId, tokenHash: tokenHash.data };
	return success({ invitation, token });
}

Insert invitation and mail a link carrying token. When the link comes back, hash the presented token with hashInvitationToken and look the row up by tokenHash. A value you compare rather than look up, such as the OAuth state kept in a cookie, goes through timingSafeEqual from @sdxc/crypto, which compares in constant time.

Keep UUIDs out of secrets

A UUID is built to be an identifier, and identifiers travel into URLs, logs, error reports and support tickets. A v7 value leaves only 62 random bits after its timestamp and counter, and RFC 9562, which defines both versions, tells implementations to assume a UUID is guessable. Keep the two apart in your types: a record's id is a UUID, and a token is a string the code only hashes or compares.

Test what each generator promises

Randomness cannot be asserted, so pin the observable contract: v7 order, the token's shape, and that the stored digest finds the token without containing it:

app/services/invitations.test.ts
import { unwrap } from "@sdxc/result";
import { generateUUID } from "@sdxc/uuid/v7";
import { expect, test } from "vitest";

import { hashInvitationToken, issueInvitation } from "~/app/services/invitations";

test("v7 ids sort in the order they were minted", () => {
	let ids = Array.from({ length: 1000 }, () => generateUUID());
	expect([...ids].sort()).toEqual(ids);
});

test("stores a digest that finds the token and never the token", async () => {
	let { invitation, token } = unwrap(await issueInvitation(generateUUID()));

	expect(token).toMatch(/^inv_[A-Za-z0-9_-]{43}$/);
	expect(invitation.tokenHash).not.toContain(token);
	expect(unwrap(await hashInvitationToken(token))).toBe(invitation.tokenHash);
});

Run it from the terminal

For a fixture, a seed row or an id someone pasted into a ticket, @sdxc/uuid and @sdxc/crypto mint and check each kind of value from a terminal:

Terminal
sdxc pkg uuid v7 -n 5
sdxc pkg uuid v4
sdxc pkg uuid validate 019a6f3e-5c21-7a4b-9d3e-8f1a2b3c4d5e
sdxc pkg crypto token --prefix sk

validate prints the version, and the creation time for a v7, and exits 1 for anything outside lowercase 8-4-4-4-12 hex. Use a package from the terminal covers what every package command line shares.

Where to go next

Documentation

Can’t find it? Contact us