A headless content API has two kinds of callers, and it's tempting to secure them the same way. That's the mistake.
There's a human logging into an admin UI to edit content, and there's a machine — a website, a build step — pulling published content through a delivery endpoint. They authenticate with different credentials, and those credentials have opposite properties. Treat them identically and you either make the machine path painfully slow or the human path dangerously weak.
I built a small headless content API (Depot) partly to get this boundary right. Here's the reasoning.
The two credentials
A session proves "this human is logged in." Short-lived, rides in an httpOnly cookie, checked on management routes.
A delivery token proves "this machine may read this account's published content." Long-lived, sent as a Bearer header, checked on every public read.
Two auth surfaces, kept explicit:
/**
* - requireUser() — admin session (httpOnly JWT cookie) for the management API.
* - requireToken() — a `depot_…` bearer token for the public delivery API.
*/
Why they get different hashing
Here's the part people get wrong. Both credentials get stored as hashes — never plaintext — but not the same kind of hash, and the reason is entropy.
Passwords are low-entropy. Humans pick summer2024. An attacker who steals your DB will brute-force guesses against the stored hashes, so you want hashing to be deliberately slow — that's exactly what bcrypt's cost factor buys you:
import bcrypt from "bcryptjs";
const ROUNDS = 10; // deliberately slow — the point is to resist brute force
export function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, ROUNDS);
}
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
Tokens are high-entropy. I generate them — 32 random bytes — so there's nothing to guess. A stolen hash can't be reversed by brute force because the keyspace is astronomically large. That means I don't need bcrypt's slowness, and I very much don't want it: this hash runs on every single delivery request. So tokens get a fast SHA-256:
/**
* A token is `depot_<43 base64url chars>` — 32 bytes of entropy. Because it's
* high-entropy we store a fast SHA-256 hash (not bcrypt): bcrypt is for
* low-entropy human passwords; for random tokens SHA-256 is safe and fast
* enough to hash on every delivery request. The plaintext is shown once.
*/
export function generateToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return PREFIX + toBase64Url(bytes);
}
export async function hashToken(token: string): Promise<string> {
const data = new TextEncoder().encode(token);
const digest = await crypto.subtle.digest("SHA-256", data);
return toHex(new Uint8Array(digest));
}
The rule of thumb: bcrypt for what humans choose, SHA-256 for what you generate. Using bcrypt on a per-request delivery token would add real latency to every read for zero security gain. Using SHA-256 on a password would be a serious vulnerability. Same "hash the secret" instinct, opposite implementations.
One more detail: the plaintext token is shown once, at creation. After that only the hash exists in the DB. If the database leaks, the tokens in it are useless — same guarantee as passwords.
The guard, and the ownership check hiding in it
The token guard does the read-path auth. Note what it returns:
export async function requireToken(req: Request) {
const token = bearer(req);
if (!token || !looksLikeToken(token)) throw unauthorized("Provide a delivery token");
const hash = await hashToken(token);
const row = await db.query.apiTokens.findFirst({
where: eq(apiTokens.tokenHash, hash),
});
if (!row) throw unauthorized("Invalid delivery token");
// Fire-and-forget last-used bookkeeping; never block the read on it.
void db.update(apiTokens)
.set({ lastUsedAt: new Date() })
.where(eq(apiTokens.id, row.id))
.catch(() => {});
return { userId: row.userId, allowedOrigins: row.allowedOrigins };
}
Two things worth calling out.
It returns userId, not just "valid: true". Every downstream query scopes to that user. A valid token for account A can never read account B's content — the ownership boundary is carried by the auth result itself, not re-checked ad hoc in each route. Getting this wrong is how multi-tenant APIs leak data across tenants.
Last-used tracking is fire-and-forget. I want to know when a token was last used, but I will not make a content read wait on a bookkeeping write — void + .catch(() => {}) means it updates opportunistically and can never fail the request it's attached to.
Rate limiting, isolated so it's testable
The delivery API is public, so it needs a rate limit. The interesting decision here isn't the algorithm (fixed window) — it's that the algorithm is a pure function with no framework imports, so it can be unit-tested in isolation:
/**
* Pure fixed-window rate-limiting algorithm — no framework imports, so it can
* be unit-tested under `node --test`. `rate-limit.ts` wraps this with the HTTP
* concerns (throwing 429s, deriving the client key).
*/
export function checkLimit(key: string, opts: RateLimit, now = Date.now()) {
const existing = buckets.get(key);
if (!existing || now >= existing.resetAt) {
const resetAt = now + opts.windowMs;
buckets.set(key, { count: 1, resetAt });
return { ok: true, remaining: opts.limit - 1, resetAt };
}
existing.count += 1;
const remaining = Math.max(0, opts.limit - existing.count);
return { ok: existing.count <= opts.limit, remaining, resetAt: existing.resetAt };
}
The now = Date.now() default is the whole trick: tests inject a fixed clock and assert window behaviour deterministically, with no setTimeout and no flakiness. Security-adjacent logic is exactly the code you want pinned by fast, boring tests.
CORS, scoped per token
The delivery API is meant to be called from browsers, so it needs CORS. Blanket Access-Control-Allow-Origin: * on an authenticated endpoint is the lazy answer. Instead each token carries its own allowedOrigins — the guard returns them, and the response echoes the origin only if it's on that token's allowlist. The reach of a token is scoped at the token level, not globally.
The takeaway
Auth isn't a layer you add at the end — it's a set of decisions about the shape of your credentials:
- Match the hash to the secret's entropy. bcrypt (slow) for human passwords, SHA-256 (fast) for high-entropy generated tokens.
- Show generated secrets once, store only the hash. A DB leak should hand the attacker nothing usable.
- Carry the ownership boundary in the auth result, so tenant isolation isn't re-litigated per route.
- Isolate security-adjacent logic (rate limiting) from the framework so it's deterministically testable. None of these are exotic. They're just decisions that are cheap to make correctly up front and expensive to retrofit.
Depot is open source — code and a live demo. It's part of a set of Contentful/Next.js projects I keep public at vitaliipopov.dev.
Top comments (0)