API keys & JWT

Who is calling, and what are they allowed to do?

What it is

An API key is a long random secret a program sends with every request (Authorization: Bearer lab_…). The API stores only its SHA-256 hash, so a leaked database doesn't leak working keys, and shows the key itself exactly once. Each key carries scopes: what it may do. A key can be exchanged for a JWT: a signed token that says who the caller is and what they may do until it expires 15 minutes later. The API verifies the signature instead of looking anything up, which is fast and stateless, but means a token can't be revoked early. 401 means "who are you?" (missing or bad credentials); 403 means "I know who you are, and no" (missing scope).

When to use it

  • Server-to-server calls and scripts: API keys
  • Many services checking the same caller without a shared database: JWTs
  • Giving a client only the access it needs: scopes
Official docs ↗

From no credentials to a scoped token

401, with WWW-Authenticate: Bearer telling the client how to authenticate.

GET/api/me

Press Send to see the response.

Keys here use SHA-256 because they're 256 random bits: unguessable, so a fast hash is enough. Passwords are the opposite (short, human-chosen), which is why they need a deliberately slow hash such as Argon2 or bcrypt. Tokens are HS256-signed with a secret only the API knows (JWT_SECRET), and checked for issuer, audience and expiry.

Show code · src/api/auth.ts
import type { Context, MiddlewareHandler } from "hono";
import { sign, verify } from "hono/jwt";
import { and, eq, gt, isNull } from "drizzle-orm";
import { db } from "./db/client";
import { apiKeys } from "./db/schema";
import { problem } from "./problem";

// Two kinds of credentials, both sent as "Authorization: Bearer …":
// - API keys ("lab_…"): long-lived, looked up in the database on every
//   request, revocable instantly.
// - JWTs, issued in exchange for a key: short-lived (15 minutes), verified
//   with a signature alone, no database lookup, so they can't be revoked
//   before they expire. That's the trade-off.

export const SCOPES = ["stats:read", "tasks:export"] as const;
export type Scope = (typeof SCOPES)[number];

export type Auth = { method: "api-key" | "jwt"; keyId: string; name: string; scopes: Scope[]; expiresAt: string };
export type AuthEnv = { Variables: { auth?: Auth } };

export const ISSUER = "https://api.djmiskey.com";
export const AUDIENCE = "api-feature-lab";
export const TOKEN_TTL = 15 * 60;

/** The HMAC secret for JWTs. Required in production; a fixed value elsewhere. */
export function jwtSecret() {
  if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
  // Production (with a real database) must have its own secret: fail closed.
  return process.env.DATABASE_URL ? null : "dev-only-jwt-secret-not-for-production";
}

const toHex = (buf: ArrayBuffer) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");

/**
 * Keys carry 256 random bits, so a plain SHA-256 is the right hash: nobody
 * can guess or brute-force one. (Passwords are different: people pick weak
 * ones, so those need a slow hash like Argon2 or bcrypt.)
 */
export const hashKey = async (key: string) => toHex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key)));

export function newKey() {
  const bytes = crypto.getRandomValues(new Uint8Array(32));
  return `lab_${btoa(String.fromCharCode(...bytes)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_")}`;
}

const challenge = (c: Context, params: string) => c.header("WWW-Authenticate", `Bearer realm="${new URL(ISSUER).host}"${params}`);

/** 401: no credentials, or ones that don't work. RFC 6750 says which. */
export function unauthorized(c: Context, detail: string, invalid = true) {
  challenge(c, invalid ? `, error="invalid_token", error_description="${detail}"` : "");
  return problem(c, 401, "unauthorized", { detail });
}

/** 403: the credentials are fine, but don't carry the scope this needs. */
export function forbidden(c: Context, scope: Scope) {
  challenge(c, `, error="insufficient_scope", scope="${scope}"`);
  return problem(c, 403, "forbidden", { detail: `This needs the "${scope}" scope.` });
}

async function fromApiKey(key: string): Promise<Auth | null> {
  const d = await db();
  const [row] = await d.select().from(apiKeys)
    .where(and(eq(apiKeys.hash, await hashKey(key)), isNull(apiKeys.revokedAt), gt(apiKeys.expiresAt, new Date())));
  if (!row) return null;
  await d.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id));
  return { method: "api-key", keyId: row.id, name: row.name, scopes: row.scopes as Scope[], expiresAt: row.expiresAt.toISOString() };
}

async function fromJwt(token: string): Promise<Auth | null> {
  const secret = jwtSecret();
  if (!secret) return null;
  try {
    const claims = await verify(token, secret, { alg: "HS256", iss: ISSUER, aud: AUDIENCE });
    return {
      method: "jwt",
      keyId: String(claims.sub),
      name: String(claims.name ?? ""),
      scopes: String(claims.scope ?? "").split(" ").filter(Boolean) as Scope[],
      expiresAt: new Date(Number(claims.exp) * 1000).toISOString(),
    };
  } catch {
    return null; // bad signature, expired, wrong issuer or audience
  }
}

/**
 * Runs on every request. No Authorization header: anonymous, which is fine
 * for public endpoints. A header that doesn't check out is always a 401,
 * even on a public endpoint: silently ignoring a bad credential hides bugs.
 */
export const authenticate: MiddlewareHandler<AuthEnv> = async (c, next) => {
  const header = c.req.header("authorization");
  if (!header) return next();
  const [scheme, credential] = header.split(" ");
  if (scheme?.toLowerCase() !== "bearer" || !credential) return unauthorized(c, "Use Authorization: Bearer <key or token>");
  const auth = credential.startsWith("lab_") ? await fromApiKey(credential) : await fromJwt(credential);
  if (!auth) return unauthorized(c, credential.startsWith("lab_") ? "Unknown, revoked or expired API key" : "Invalid or expired token");
  c.set("auth", auth);
  return next();
};

/** For protected routes: a 401 without (valid) credentials. */
export function requireAuth(c: Context<AuthEnv>) {
  const auth = c.get("auth");
  return auth ? { auth } : { error: unauthorized(c, "This endpoint needs an API key or token", false) };
}

/** As requireAuth, plus a 403 if the credentials lack the scope. */
export function requireScope(c: Context<AuthEnv>, scope: Scope) {
  const { auth, error } = requireAuth(c);
  if (error) return { error };
  return auth.scopes.includes(scope) ? { auth } : { error: forbidden(c, scope) };
}

export async function issueToken(auth: Auth, secret: string) {
  const now = Math.floor(Date.now() / 1000);
  return sign({ sub: auth.keyId, name: auth.name, scope: auth.scopes.join(" "), iss: ISSUER, aud: AUDIENCE, iat: now, exp: now + TOKEN_TTL }, secret, "HS256");
}
Show code · src/api/routes/auth.ts
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { and, count, desc, eq, gt, isNull, notInArray } from "drizzle-orm";
import { type AuthEnv, issueToken, jwtSecret, newKey, hashKey, requireAuth, requireScope, SCOPES, TOKEN_TTL } from "../auth";
import { db } from "../db/client";
import { apiKeys, tasks } from "../db/schema";
import { Problem, problem } from "../problem";
import { rateLimit } from "../ratelimit";
import { validationHook } from "../validation";

const KEY_TTL_HOURS = 24; // the lab is public: keys expire after a day
const MAX_KEYS = 500;

const json = <T extends z.ZodType>(schema: T, description: string) => ({ content: { "application/json": { schema } }, description });
const problemResponse = (description: string) => ({ content: { "application/problem+json": { schema: Problem } }, description });
const bearer = [{ bearer: [] }];

const NewKey = z.strictObject({
  name: z.string().trim().min(1, "Name the key").max(60),
  scopes: z.array(z.enum(SCOPES)).max(SCOPES.length).default([]),
}).openapi("NewKey", { example: { name: "My script", scopes: ["stats:read"] } });

const CreatedKey = z.object({
  id: z.uuid(), name: z.string(), prefix: z.string(), scopes: z.array(z.enum(SCOPES)), expiresAt: z.iso.datetime(),
  key: z.string().openapi({ description: "The key itself. Shown only now: only its hash is stored" }),
}).openapi("CreatedKey");

const Identity = z.object({
  method: z.enum(["api-key", "jwt"]), keyId: z.uuid(), name: z.string(), scopes: z.array(z.enum(SCOPES)), expiresAt: z.iso.datetime(),
}).openapi("Identity");

const Token = z.object({
  access_token: z.string(), token_type: z.literal("Bearer"), expires_in: z.number().int(), scope: z.string(),
}).openapi("Token");

export const authApi = new OpenAPIHono<AuthEnv>({ defaultHook: validationHook });

authApi.openAPIRegistry.registerComponent("securitySchemes", "bearer", {
  type: "http", scheme: "bearer", description: "An API key (lab_…) or a JWT from POST /api/token",
});

authApi.openapi(
  createRoute({
    method: "post", path: "/keys", tags: ["Auth"], summary: "Create an API key (shown once)",
    middleware: [rateLimit({ name: "keys", windowSec: 3600, limit: () => Number(process.env.LAB_KEY_LIMIT ?? 5) })] as const,
    request: { body: { content: { "application/json": { schema: NewKey } }, required: true } },
    responses: { 201: json(CreatedKey, "The new key. Store it now: it can't be shown again"), 422: problemResponse("Invalid name or scope"), 429: problemResponse("Too many keys created") },
  }),
  async (c) => {
    const { name, scopes } = c.req.valid("json");
    const key = newKey();
    const d = await db();
    const [row] = await d.insert(apiKeys).values({
      name, scopes, hash: await hashKey(key), prefix: key.slice(0, 12), expiresAt: new Date(Date.now() + KEY_TTL_HOURS * 3_600_000),
    }).returning();
    const keep = d.select({ id: apiKeys.id }).from(apiKeys).orderBy(desc(apiKeys.createdAt)).limit(MAX_KEYS);
    await d.delete(apiKeys).where(notInArray(apiKeys.id, keep));
    c.header("Cache-Control", "no-store"); // a secret: never cache this response
    return c.json({ id: row.id, name: row.name, prefix: row.prefix, scopes: row.scopes as typeof scopes, expiresAt: row.expiresAt.toISOString(), key }, 201);
  },
);

authApi.openapi(
  createRoute({
    method: "delete", path: "/keys/{id}", tags: ["Auth"], summary: "Revoke a key (authenticate with that same key)", security: bearer,
    request: { params: z.object({ id: z.uuid().openapi({ param: { name: "id", in: "path" } }) }) },
    responses: { 204: { description: "Revoked: the key stops working at once" }, 401: problemResponse("No or bad credentials"), 403: problemResponse("Not your key, or not an API key") },
  }),
  async (c) => {
    const { auth, error } = requireAuth(c);
    if (error) return error;
    const { id } = c.req.valid("param");
    if (auth.method !== "api-key" || auth.keyId !== id) return problem(c, 403, "forbidden", { detail: "A key can only revoke itself: send it in Authorization." });
    await (await db()).update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
    return c.body(null, 204);
  },
);

authApi.openapi(
  createRoute({
    method: "post", path: "/token", tags: ["Auth"], summary: "Exchange an API key for a 15-minute JWT", security: bearer,
    middleware: [rateLimit({ name: "token", windowSec: 60, limit: 20 })] as const,
    responses: {
      200: json(Token, "A signed, short-lived access token (OAuth 2 style response)"),
      401: problemResponse("No or bad API key"), 403: problemResponse("Sent a token, not a key"), 503: problemResponse("Tokens aren't configured"),
    },
  }),
  async (c) => {
    const { auth, error } = requireAuth(c);
    if (error) return error;
    if (auth.method !== "api-key") return problem(c, 403, "forbidden", { detail: "Exchange an API key, not a token: tokens can't mint tokens." });
    const secret = jwtSecret();
    if (!secret) return problem(c, 503, "unavailable", { detail: "JWT_SECRET isn't set on this deployment." });
    c.header("Cache-Control", "no-store");
    return c.json({ access_token: await issueToken(auth, secret), token_type: "Bearer" as const, expires_in: TOKEN_TTL, scope: auth.scopes.join(" ") }, 200);
  },
);

authApi.openapi(
  createRoute({
    method: "get", path: "/me", tags: ["Auth"], summary: "Who am I? (any valid key or token)", security: bearer,
    responses: { 200: json(Identity, "The caller's identity and scopes"), 401: problemResponse("No or bad credentials") },
  }),
  async (c) => {
    const { auth, error } = requireAuth(c);
    return error ?? c.json(auth, 200);
  },
);

authApi.openapi(
  createRoute({
    method: "get", path: "/stats", tags: ["Auth"], summary: "Task and key counts (needs stats:read)", security: bearer,
    responses: {
      200: json(z.object({ tasks: z.object({ total: z.number(), open: z.number(), done: z.number() }), activeKeys: z.number() }), "The numbers"),
      401: problemResponse("No or bad credentials"), 403: problemResponse("Missing the stats:read scope"),
    },
  }),
  async (c) => {
    const { error } = requireScope(c, "stats:read");
    if (error) return error;
    const d = await db();
    const byDone = await d.select({ done: tasks.done, n: count() }).from(tasks).groupBy(tasks.done);
    const [keys] = await d.select({ n: count() }).from(apiKeys).where(and(isNull(apiKeys.revokedAt), gt(apiKeys.expiresAt, new Date())));
    const done = byDone.find((r) => r.done)?.n ?? 0;
    const open = byDone.find((r) => !r.done)?.n ?? 0;
    return c.json({ tasks: { total: done + open, open, done }, activeKeys: keys.n }, 200);
  },
);

authApi.openapi(
  createRoute({
    method: "get", path: "/export/tasks", tags: ["Auth"], summary: "All tasks as CSV (needs tasks:export)", security: bearer,
    responses: {
      200: { content: { "text/csv": { schema: z.string() } }, description: "One row per task" },
      401: problemResponse("No or bad credentials"), 403: problemResponse("Missing the tasks:export scope"),
    },
  }),
  async (c) => {
    const { error } = requireScope(c, "tasks:export");
    if (error) return error;
    const rows = await (await db()).select().from(tasks).orderBy(desc(tasks.createdAt));
    const cell = (v: string) => `"${v.replace(/"/g, '""')}"`;
    const csv = ["id,title,done,created_at", ...rows.map((r) => [r.id, cell(r.title), r.done, r.createdAt.toISOString()].join(","))].join("\n");
    return c.text(csv, 200, { "content-type": "text/csv; charset=utf-8", "content-disposition": 'attachment; filename="tasks.csv"' });
  },
);