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:
- The user’s role has the required permission (via the membership)
- 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:
- Role check: Does the user’s role in this org include the permission? (Same as before.)
- 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?