Skip to main content
Role-Based Access Control (RBAC) lets you define what each API key is allowed to do. Instead of treating all keys equally, you can grant specific permissions based on roles, enabling fine-grained authorization for your API.

How RBAC works in Unkey

Unkey’s authorization system has three components:
1

Permissions

Individual capabilities like documents.read or users.delete. These represent atomic actions in your system.
2

Roles

Collections of permissions. A role like editor might include documents.read, documents.write, and documents.delete.
3

Keys

API keys are assigned one or more roles. When a key is verified, you can check if it has the required permissions.
Keys inherit permissions from all assigned roles. A key with both editor and viewer roles will have permissions from both.

Setting up authorization

Define your permission model

Start by mapping out what actions your API supports. Use a hierarchical naming convention:
resource.action

Examples:
- documents.read
- documents.write
- documents.delete
- users.create
- users.delete
- billing.view
- billing.manage
For complex systems, use deeper hierarchies:
domain.dns.create_record
domain.dns.update_record
domain.dns.delete_record
domain.create_domain
domain.delete_domain

Create permissions

Create permissions via the dashboard or API:
  1. Navigate to AuthorizationPermissions in your Unkey dashboard
  2. Click Create New Permission
  3. Enter a name (human-readable) and slug (your permission identifier)
  4. Add an optional description
  5. Click Create new permission

Create roles

Group permissions into logical roles:
  1. Navigate to AuthorizationRoles
  2. Click Create New Role
  3. Enter a unique role name (e.g., admin, editor, viewer)
  4. Optionally add a description
  5. Select permissions to attach
  6. Click Create new role

Attach roles to keys

When creating or updating keys, assign one or more roles:
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "apiId": "api_...",
    "roles": ["editor", "viewer"],
    "name": "Production API Key"
  }'

Verifying permissions

During key verification, check if the key has required permissions. If not, verification fails with code: INSUFFICIENT_PERMISSIONS.

Single permission check

import { verifyKey } from "@unkey/api";

try {
  const { meta, data } = await verifyKey({
    key: request.headers.get("authorization")?.replace("Bearer ", ""),
    permissions: "documents.read",
  });

  if (!data.valid) {
    if (data.code === "INSUFFICIENT_PERMISSIONS") {
      return Response.json(
        { error: "You don't have permission to read documents" },
        { status: 403 }
      );
    }
    return Response.json({ error: data.code }, { status: 401 });
  }

  // Permission granted - continue with your logic
  const documents = await db.documents.findMany();
  return Response.json({ documents });
} catch (error) {
  console.error(error);
  return Response.json({ error: "Internal error" }, { status: 500 });
}

Complex permission queries

Unkey supports logical operators for sophisticated checks:
const { meta, data } = await verifyKey({
  key: "sk_...",
  permissions: "documents.read AND documents.write",
});
Key must have both permissions.
const { meta, data } = await verifyKey({
  key: "sk_...",
  permissions: "admin OR editor",
});
Key needs at least one of the specified permissions.
const { meta, data } = await verifyKey({
  key: "sk_...",
  permissions: "admin OR (documents.delete AND documents.write)",
});
Key must be an admin OR have both delete and write permissions.

Wildcard permissions

Use wildcards to grant broad access without listing individual permissions:
// Key has: ["documents.*"]
// This grants: documents.read, documents.write, documents.delete, etc.

const { meta, data } = await verifyKey({
  key: "sk_...",
  permissions: "documents.read",  // ✅ Matches documents.*
});
Common patterns:
  • * — All permissions (admin-level access)
  • documents.* — All document operations
  • api.v1.* — All v1 API endpoints
  • tenant.*.read — Read access to all tenant resources
Use * sparingly. It’s better to create an admin role with explicit permissions than to use wildcard matching.

Advanced patterns

Context-aware authorization

Sometimes you need to check permissions after loading data from your database:
try {
  // Step 1: Verify the key and get its permissions
  const { meta, data } = await verifyKey({
    key: request.apiKey,
  });

  if (!data.valid) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const permissions = data.permissions ?? [];

  // Step 2: Load the resource
  const document = await db.documents.findUnique({
    where: { id: documentId },
  });

  if (!document) {
    return Response.json({ error: "Not found" }, { status: 404 });
  }

  // Step 3: Check permissions based on ownership
  const canDelete =
    permissions.includes("admin") ||
    (permissions.includes("documents.delete") &&
      document.ownerId === data.identity?.externalId);

  if (!canDelete) {
    return Response.json(
      { error: "You can only delete your own documents" },
      { status: 403 }
    );
  }

  // Permission granted
  await db.documents.delete({ where: { id: documentId } });
  return Response.json({ success: true });
} catch (error) {
  console.error(error);
  return Response.json({ error: "Internal error" }, { status: 500 });
}

Permission hierarchies

Design permissions to reflect resource hierarchies:
// Organization structure:
// org.members.invite
// org.members.remove
// org.billing.view
// org.billing.manage
// org.settings.update

// Project-specific permissions:
// project.deployments.create
// project.deployments.rollback
// project.env.read
// project.env.write

// Grant org-level admin:
const { meta, data } = await unkey.keys.create({
  apiId: "api_...",
  roles: ["org.admin"],  // Has all org.* permissions
});

API endpoint protection

Map HTTP routes to permissions:
const permissionMap = {
  "GET /api/documents": "documents.read",
  "POST /api/documents": "documents.write",
  "DELETE /api/documents/:id": "documents.delete",
  "GET /api/users": "users.read",
  "POST /api/users": "users.create",
} as const;

export async function middleware(request: Request) {
  const route = `${request.method} ${new URL(request.url).pathname}`;
  const requiredPermission = permissionMap[route];

  if (!requiredPermission) {
    return Response.json({ error: "Route not found" }, { status: 404 });
  }

  const apiKey = request.headers.get("authorization")?.replace("Bearer ", "");

  try {
    const { meta, data } = await verifyKey({
      key: apiKey,
      permissions: requiredPermission,
    });

    if (!data.valid) {
      return Response.json({ error: data.code }, { status: 403 });
    }

    // Attach permissions to request for downstream use
    request.headers.set("X-User-Permissions", JSON.stringify(data.permissions));
    return null; // Continue to route handler
  } catch (error) {
    console.error(error);
    return Response.json({ error: "Internal error" }, { status: 500 });
  }
}

Real-world example: Domain management API

Let’s build a complete RBAC system for a domain management platform:
1

Define permissions

domain.create_domain
domain.read_domain
domain.update_domain
domain.delete_domain
domain.dns.create_record
domain.dns.read_record
domain.dns.update_record
domain.dns.delete_record
2

Create roles

admin — Full access to everything
  • All permissions
dns.manager — Can manage DNS records but not domains
  • domain.dns.create_record
  • domain.dns.read_record
  • domain.dns.update_record
  • domain.dns.delete_record
read-only — View-only access
  • domain.read_domain
  • domain.dns.read_record
3

Assign roles to keys

// Admin user gets full access
await unkey.keys.create({
  apiId: "api_...",
  roles: ["admin"],
  name: "Admin API Key",
});

// DNS manager for automated systems
await unkey.keys.create({
  apiId: "api_...",
  roles: ["dns.manager"],
  name: "DNS Automation Key",
});

// Monitoring system gets read-only access
await unkey.keys.create({
  apiId: "api_...",
  roles: ["read-only"],
  name: "Monitoring Key",
});
4

Verify in your API

// DELETE /api/domains/:id/dns/:recordId
export async function DELETE(request: Request) {
  const { meta, data } = await verifyKey({
    key: request.apiKey,
    permissions: "domain.dns.delete_record",
  });

  if (!data.valid) {
    return Response.json({ error: data.code }, { status: 403 });
  }

  // Permission granted - delete the DNS record
  await deleteDnsRecord(recordId);
  return Response.json({ success: true });
}

Best practices

Use specific permissions

Define users.delete instead of admin. Granular permissions give you better audit trails and easier debugging.

Check permissions server-side

Never rely on client-side checks. Always verify permissions in your API, even if the UI hides certain actions.

Group with roles

Instead of attaching 15 permissions to every key, create roles and attach those. Easier to manage and update.

Start broad, then narrow

Begin with a few roles (admin, user), then split them as your needs grow. Premature optimization leads to complexity.
Don’t commit files with sensitive permissions. Avoid hardcoding permission checks in your codebase. Instead, fetch the permission requirements dynamically or store them in configuration.

Response structure

Successful verification with permissions:
{
  "meta": { "requestId": "req_..." },
  "data": {
    "valid": true,
    "code": "VALID",
    "keyId": "key_...",
    "permissions": ["documents.read", "documents.write", "users.view"]
  }
}
Failed permission check:
{
  "meta": { "requestId": "req_..." },
  "data": {
    "valid": false,
    "code": "INSUFFICIENT_PERMISSIONS",
    "keyId": "key_..."
  }
}

Next steps

Multi-tenant applications

Use identities to scope permissions per organization

Rate limit overrides

Adjust rate limits based on user roles

Build docs developers (and LLMs) love