cd /news/developer-tools/two-credentials-two-threat-models-au… · home topics developer-tools article
[ARTICLE · art-71027] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer building a headless content API called Depot explains the need for two distinct authentication strategies: slow bcrypt hashing for low-entropy human passwords and fast SHA-256 hashing for high-entropy machine tokens. The project uses session cookies for admin users and bearer tokens for delivery endpoints, with the rule of thumb being 'bcrypt for what humans choose, SHA-256 for what you generate.'

read5 min views1 publishedJul 23, 2026

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.

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:

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:

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @depot 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/two-credentials-two-…] indexed:0 read:5min 2026-07-23 ·