hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Authorization with @hectoday/http

Beyond Authentication

  • Authentication vs. Authorization
  • Project Setup

Role-Based Access Control (RBAC)

  • Roles and What They Mean
  • Checking Roles in Route Handlers
  • Role Hierarchy

Permission-Based Access Control

  • From Roles to Permissions
  • Checking Permissions
  • Custom Permissions

Organization Scoping

  • Multi-Tenancy
  • Switching Organizations
  • Inviting Members

API Keys and Scoping

  • API Keys
  • Scoped API Keys

Putting It All Together

  • Policy Functions
  • Audit Logging
  • Authorization Checklist
  • Capstone: Multi-Tenant Notes API

API Keys

Why API keys

Sessions use cookies, which are browser-specific. JWTs use the Authorization header, which works for any client but requires a login flow. API keys are for non-interactive use: scripts, CI/CD pipelines, integrations, and automated tools that need to call your API without a human logging in.

An API key is a long random string that the client includes in a header. The server validates it and identifies the user and organization.

The API keys table

CREATE TABLE IF NOT EXISTS api_keys (
  id TEXT PRIMARY KEY,
  key_hash TEXT NOT NULL UNIQUE,
  key_prefix TEXT NOT NULL,
  user_id TEXT NOT NULL,
  org_id TEXT NOT NULL,
  name TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY (user_id) REFERENCES users(id),
  FOREIGN KEY (org_id) REFERENCES organizations(id)
);

Add this to src/db.ts.

We store key_hash (the SHA-256 hash of the key, not the key itself) and key_prefix (the first 8 characters, for display: “sk_a3f2…”). This is the same pattern as password reset tokens in the Securing Your API course: hash the secret before storing it.

Generating a key

// src/api-keys.ts
import db from "./db.js";

async function hashKey(key: string): Promise<string> {
  const data = new TextEncoder().encode(key);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

export async function createApiKey(userId: string, orgId: string, name: string): Promise<string> {
  const key = `sk_${crypto.randomUUID().replace(/-/g, "")}`;
  const keyHash = await hashKey(key);
  const keyPrefix = key.slice(0, 10);
  const id = crypto.randomUUID();

  db.prepare(
    "INSERT INTO api_keys (id, key_hash, key_prefix, user_id, org_id, name) VALUES (?, ?, ?, ?, ?, ?)",
  ).run(id, keyHash, keyPrefix, userId, orgId, name);

  return key; // Return the unhashed key to the user ONCE. It cannot be retrieved later.
}

export async function validateApiKey(
  key: string,
): Promise<{ userId: string; orgId: string } | null> {
  const keyHash = await hashKey(key);
  const row = db
    .prepare("SELECT user_id, org_id FROM api_keys WHERE key_hash = ?")
    .get(keyHash) as any;
  return row ? { userId: row.user_id, orgId: row.org_id } : null;
}

The key is prefixed with sk_ (secret key) to make it identifiable. The full key is returned to the user once at creation time. After that, only the hash is stored. If the user loses the key, they must generate a new one.

[!WARNING] The API key is only shown once when created. This is the same pattern as GitHub personal access tokens and Stripe API keys. If the user loses it, they cannot retrieve it — only the hash is stored.

Authenticating with API keys

Update src/auth.ts to support both session cookies and API keys:

export function authenticate(request: Request): AuthUser | Response {
  // Try API key first (X-API-Key header)
  const apiKey = request.headers.get("x-api-key");
  if (apiKey) {
    return authenticateApiKey(apiKey);
  }

  // Fall back to session cookie
  const sessionId = getSessionId(request);
  if (!sessionId) return Response.json({ error: "Unauthorized" }, { status: 401 });

  const session = getSession(sessionId);
  if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });

  const user = db
    .prepare("SELECT id, email, name FROM users WHERE id = ?")
    .get(session.userId) as any;
  if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });

  return {
    id: user.id,
    email: user.email,
    name: user.name,
    sessionId,
    activeOrgId: session.activeOrgId,
  };
}

async function authenticateApiKey(key: string): Promise<AuthUser | Response> {
  const result = await validateApiKey(key);
  if (!result) return Response.json({ error: "Invalid API key" }, { status: 401 });

  const user = db
    .prepare("SELECT id, email, name FROM users WHERE id = ?")
    .get(result.userId) as any;
  if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });

  return {
    id: user.id,
    email: user.email,
    name: user.name,
    sessionId: "",
    activeOrgId: result.orgId,
  };
}

[!NOTE] The authenticate function now has an async path (API key validation uses SHA-256 hashing). If your routes call authenticate synchronously, you will need to update them to async and await. Alternatively, use a synchronous hash for API key lookup (like the crypto.createHash Node.js API).

API key authentication sets activeOrgId to the org the key is bound to. The key’s organization is fixed — it cannot switch orgs.

Creating keys via API

route.post("/orgs/:orgId/api-keys", {
  resolve: async (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const perm = requirePermission(user, c.params.orgId, "org:settings");
    if (perm instanceof Response) return perm;

    const body = await c.request.json();
    const name = body.name;
    if (!name) return Response.json({ error: "Name is required" }, { status: 400 });

    const key = await createApiKey(user.id, c.params.orgId, name);

    return Response.json({
      key,  // Only shown once!
      name,
      message: "Save this key — it cannot be retrieved later.",
    }, { status: 201 });
  },
}),

Exercises

Exercise 1: Create an API key for Acme. Copy the key. Use it in a request: curl -H "X-API-Key: sk_..." http://localhost:3000/orgs/org-acme/notes. Verify it works.

Exercise 2: Try using the key against Globex: curl -H "X-API-Key: sk_..." http://localhost:3000/orgs/org-globex/notes. It should fail because the key is bound to Acme.

Exercise 3: Delete the key from the database. Try the request again. It should fail with 401.

Why do we hash the API key before storing it?

← Inviting Members Scoped API Keys →

© 2026 hectoday. All rights reserved.