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

Scoped API Keys

The principle of least privilege

The API keys from the previous lesson inherit all of the user’s permissions in the organization. If Alice is an owner, her API key can do everything an owner can do: create, edit, delete notes, invite members, change settings.

This is dangerous. A key used in a CI/CD script only needs notes:read. A key for an integration might need notes:create and notes:read but not notes:delete. Giving every key full permissions violates the principle of least privilege: grant only the permissions needed for the task.

Adding scopes to keys

Update the api_keys table to include a scopes column:

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,
  scopes TEXT NOT NULL DEFAULT '[]',  -- JSON array of permission strings
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY (user_id) REFERENCES users(id),
  FOREIGN KEY (org_id) REFERENCES organizations(id)
);

The scopes column is a JSON array of permissions: ["notes:read"] for a read-only key, ["notes:read", "notes:create"] for a key that can read and create.

Updating key creation

export async function createApiKey(
  userId: string,
  orgId: string,
  name: string,
  scopes: 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, scopes) VALUES (?, ?, ?, ?, ?, ?, ?)",
  ).run(id, keyHash, keyPrefix, userId, orgId, name, JSON.stringify(scopes));

  return key;
}

Enforcing scopes

When an API key is used, the authorization check must verify both:

  1. The user’s role has the required permission (via the membership)
  2. The key’s scopes include the required permission

Update validateApiKey to return the scopes:

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

  const scopes = JSON.parse(row.scopes) as string[];
  return { userId: row.user_id, orgId: row.org_id, scopes };
}

Update the AuthUser type to include scopes:

export interface AuthUser {
  id: string;
  email: string;
  name: string;
  sessionId: string;
  activeOrgId: string | null;
  scopes: string[] | null; // null = no scope restriction (session-based auth)
}

Update requirePermission to check scopes:

export function requirePermission(
  user: AuthUser,
  orgId: string,
  permission: string,
): true | Response {
  const membership = getMembership(user.id, orgId);
  if (!membership) return Response.json({ error: "Not found" }, { status: 404 });

  // Check role permission
  if (!roleHasPermission(membership.role, permission, orgId)) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  // Check API key scopes (if applicable)
  if (user.scopes !== null && !user.scopes.includes(permission)) {
    return Response.json({ error: "Forbidden: key scope insufficient" }, { status: 403 });
  }

  return true;
}

The two checks work together:

  1. Role check: Does the user’s role in this org include the permission? (Same as before.)
  2. Scope check: If using an API key, does the key’s scope include the permission?

A key’s effective permissions are the intersection of the user’s role permissions and the key’s scopes. If Alice (owner) creates a key with scopes ["notes:read"], the key can only read notes — even though Alice’s owner role can do everything.

This is the principle of least privilege in action: the key can never do more than the user’s role allows, and it can be further restricted by scopes.

Key creation with scope validation

When creating a key, validate that the requested scopes are a subset of the user’s actual permissions:

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, scopes } = body;

    if (!name || !Array.isArray(scopes) || scopes.length === 0) {
      return Response.json({ error: "Name and non-empty scopes array required" }, { status: 400 });
    }

    // Verify the user has all requested scopes
    const membership = getMembership(user.id, c.params.orgId);
    if (!membership) return Response.json({ error: "Not found" }, { status: 404 });

    const userPerms = getPermissions(membership.role, c.params.orgId);
    for (const scope of scopes) {
      if (!userPerms.has(scope)) {
        return Response.json(
          { error: `You do not have the ${scope} permission and cannot grant it to a key` },
          { status: 403 },
        );
      }
    }

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

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

Exercises

Exercise 1: Create a read-only key: scopes: ["notes:read"]. Use it to list notes (should work). Use it to create a note (should fail with “scope insufficient”).

Exercise 2: Create a key with scopes: ["notes:read", "notes:create"]. Verify it can list and create but not delete.

Exercise 3: Try creating a key with scopes: ["org:delete"] as an editor. It should fail because editors do not have the org:delete permission.

Why are key permissions the intersection of role permissions and key scopes?

← API Keys Policy Functions →

© 2026 hectoday. All rights reserved.