# Two credentials, two threat models: auth for a content API

> Source: <https://dev.to/vitaliipopov/two-credentials-two-threat-models-auth-for-a-content-api-4eo>
> Published: 2026-07-23 21:03:19+00:00

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](https://depot.vitaliipopov.dev/)) partly to get this boundary right. Here's the reasoning.

**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.
 */
```

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:

``` python
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 token guard does the read-path auth. Note what it returns:

``` js
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

**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.

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.

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.

Auth isn't a layer you add at the end — it's a set of decisions about the *shape* of your credentials:

*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.*
