{"slug": "two-credentials-two-threat-models-auth-for-a-content-api", "title": "Two credentials, two threat models: auth for a content API", "summary": "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.'", "body_md": "A headless content API has two kinds of callers, and it's tempting to secure them the same way. That's the mistake.\n\nThere'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.\n\nI built a small headless content API ([Depot](https://depot.vitaliipopov.dev/)) partly to get this boundary right. Here's the reasoning.\n\n**A session** proves \"this human is logged in.\" Short-lived, rides in an httpOnly cookie, checked on management routes.\n\n**A delivery token** proves \"this machine may read this account's published content.\" Long-lived, sent as a `Bearer`\n\nheader, checked on every public read.\n\nTwo auth surfaces, kept explicit:\n\n```\n/**\n * - requireUser()  — admin session (httpOnly JWT cookie) for the management API.\n * - requireToken() — a `depot_…` bearer token for the public delivery API.\n */\n```\n\nHere'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.\n\n**Passwords are low-entropy.** Humans pick `summer2024`\n\n. 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:\n\n``` python\nimport bcrypt from \"bcryptjs\";\n\nconst ROUNDS = 10; // deliberately slow — the point is to resist brute force\n\nexport function hashPassword(plain: string): Promise<string> {\n  return bcrypt.hash(plain, ROUNDS);\n}\n\nexport function verifyPassword(plain: string, hash: string): Promise<boolean> {\n  return bcrypt.compare(plain, hash);\n}\n```\n\n**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:\n\n```\n/**\n * A token is `depot_<43 base64url chars>` — 32 bytes of entropy. Because it's\n * high-entropy we store a fast SHA-256 hash (not bcrypt): bcrypt is for\n * low-entropy human passwords; for random tokens SHA-256 is safe and fast\n * enough to hash on every delivery request. The plaintext is shown once.\n */\nexport function generateToken(): string {\n  const bytes = crypto.getRandomValues(new Uint8Array(32));\n  return PREFIX + toBase64Url(bytes);\n}\n\nexport async function hashToken(token: string): Promise<string> {\n  const data = new TextEncoder().encode(token);\n  const digest = await crypto.subtle.digest(\"SHA-256\", data);\n  return toHex(new Uint8Array(digest));\n}\n```\n\nThe 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.\n\nOne 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.\n\nThe token guard does the read-path auth. Note what it returns:\n\n``` js\nexport async function requireToken(req: Request) {\n  const token = bearer(req);\n  if (!token || !looksLikeToken(token)) throw unauthorized(\"Provide a delivery token\");\n\n  const hash = await hashToken(token);\n  const row = await db.query.apiTokens.findFirst({\n    where: eq(apiTokens.tokenHash, hash),\n  });\n  if (!row) throw unauthorized(\"Invalid delivery token\");\n\n  // Fire-and-forget last-used bookkeeping; never block the read on it.\n  void db.update(apiTokens)\n    .set({ lastUsedAt: new Date() })\n    .where(eq(apiTokens.id, row.id))\n    .catch(() => {});\n\n  return { userId: row.userId, allowedOrigins: row.allowedOrigins };\n}\n```\n\nTwo things worth calling out.\n\n**It returns userId, not just \"valid: true\".** Every downstream query scopes to\n\n**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`\n\n+ `.catch(() => {})`\n\nmeans it updates opportunistically and can never fail the request it's attached to.\n\nThe 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:\n\n```\n/**\n * Pure fixed-window rate-limiting algorithm — no framework imports, so it can\n * be unit-tested under `node --test`. `rate-limit.ts` wraps this with the HTTP\n * concerns (throwing 429s, deriving the client key).\n */\nexport function checkLimit(key: string, opts: RateLimit, now = Date.now()) {\n  const existing = buckets.get(key);\n  if (!existing || now >= existing.resetAt) {\n    const resetAt = now + opts.windowMs;\n    buckets.set(key, { count: 1, resetAt });\n    return { ok: true, remaining: opts.limit - 1, resetAt };\n  }\n  existing.count += 1;\n  const remaining = Math.max(0, opts.limit - existing.count);\n  return { ok: existing.count <= opts.limit, remaining, resetAt: existing.resetAt };\n}\n```\n\nThe `now = Date.now()`\n\ndefault is the whole trick: tests inject a fixed clock and assert window behaviour deterministically, with no `setTimeout`\n\nand no flakiness. Security-adjacent logic is exactly the code you want pinned by fast, boring tests.\n\nThe delivery API is meant to be called from browsers, so it needs CORS. Blanket `Access-Control-Allow-Origin: *`\n\non an authenticated endpoint is the lazy answer. Instead each token carries its own `allowedOrigins`\n\n— 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.\n\nAuth isn't a layer you add at the end — it's a set of decisions about the *shape* of your credentials:\n\n*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.*", "url": "https://wpnews.pro/news/two-credentials-two-threat-models-auth-for-a-content-api", "canonical_source": "https://dev.to/vitaliipopov/two-credentials-two-threat-models-auth-for-a-content-api-4eo", "published_at": "2026-07-23 21:03:19+00:00", "updated_at": "2026-07-23 22:01:40.930109+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["Depot", "Vitalii Popov"], "alternates": {"html": "https://wpnews.pro/news/two-credentials-two-threat-models-auth-for-a-content-api", "markdown": "https://wpnews.pro/news/two-credentials-two-threat-models-auth-for-a-content-api.md", "text": "https://wpnews.pro/news/two-credentials-two-threat-models-auth-for-a-content-api.txt", "jsonld": "https://wpnews.pro/news/two-credentials-two-threat-models-auth-for-a-content-api.jsonld"}}