API Scopes & RBAC
Controlling What Tokens Can Do
Authentication answers "who are you?" Authorization answers "what can you do?" Scopes, roles, and attribute-based policies are the three primary mechanisms for making authorization decisions in APIs.
Scopes: Token-Level Permissions
A scope limits what an access token can do, regardless of the user's full permissions. When a user authorizes a third-party app, they grant specific scopes — the app gets a subset of the user's capabilities, not all of them.
GitHub's Token Scopes
GitHub's personal access tokens are a well-known example. When creating a token, you select exactly which scopes it needs:
repo Full control of private repositories
repo:status Access commit status
repo:invite Access repository invitations
read:org Read org and team membership
write:org Read and write org and team membership
read:user Read all user profile data
user:email Access user email addresses
A token with only read:user and user:email can read profile data but cannot push code, manage organizations, or delete repositories — even if the user has admin access to everything.
Scope Naming Conventions
The most common pattern is action:resource:
read:users
write:users
delete:users
read:orders
write:orders
admin:billing
Some APIs use dot notation:
users.read
users.write
orders.read
Google uses URL-style scopes:
https://www.googleapis.com/auth/gmail.readonly
https://www.googleapis.com/auth/calendar.events
Whatever convention you choose, be consistent. The action:resource pattern is the most readable and widely adopted.
Requesting Scopes in OAuth
When a client initiates an OAuth flow, it requests specific scopes:
GET /oauth/authorize?
client_id=app_456&
redirect_uri=https://myapp.com/callback&
scope=read:users write:orders&
state=abc123
The auth server issues a token with the granted scopes:
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:users write:orders"
}
If the user denies a scope, the token is issued without it. The client must handle partial grants.
Checking Scopes in Middleware
After authentication, the authorization middleware checks whether the token's scopes include the required scope for the endpoint:
GET /api/v1/users -> requires "read:users"
POST /api/v1/users -> requires "write:users"
DELETE /api/v1/users/123 -> requires "delete:users"
GET /api/v1/orders -> requires "read:orders"
If the token lacks the required scope, return 403:
{
"error": {
"type": "insufficient_scope",
"status": 403,
"message": "This endpoint requires the 'write:users' scope. Your token has: read:users, read:orders.",
"required_scope": "write:users"
}
}
Telling the client which scope is required helps developers fix their token configuration without guessing.
Role-Based Access Control (RBAC)
While scopes limit tokens, RBAC limits users. A role is a named collection of permissions assigned to a user or service account.
Defining Roles & Permissions
{
"roles": {
"viewer": {
"permissions": ["read:users", "read:orders", "read:reports"]
},
"editor": {
"permissions": ["read:users", "write:users", "read:orders", "write:orders", "read:reports"]
},
"admin": {
"permissions": ["read:users", "write:users", "delete:users", "read:orders", "write:orders", "delete:orders", "read:reports", "write:reports", "manage:billing"]
}
}
}
How RBAC Works in Practice
When a request arrives:
1. Authentication: identify the user (from token)
2. Look up the user's role(s)
3. Resolve the role to a set of permissions
4. Check if the required permission is in the set
5. Allow or deny the request
A user can have multiple roles. Permissions from all roles are combined (union). Stripe's dashboard, for example, supports roles like Administrator, Developer, Analyst, and Support Specialist, each with different API access levels.
Role Hierarchies
Many systems define role hierarchies where higher roles inherit permissions from lower ones:
admin -> editor -> viewer
admin has: all editor permissions + delete:users, manage:billing, ...
editor has: all viewer permissions + write:users, write:orders, ...
viewer has: read:users, read:orders, read:reports
This reduces duplication but can make the permission model harder to reason about. Keep hierarchies shallow — two or three levels at most.
Attribute-Based Access Control (ABAC)
ABAC evaluates authorization rules based on attributes of the user, the resource, the action, and the environment. It handles complex rules that RBAC cannot express cleanly.
When RBAC Falls Short
Consider these requirements:
- "Users can only edit their own profile"
- "Managers can approve expenses only for their direct reports"
- "Documents can only be accessed during business hours from corporate IPs"
RBAC cannot express "own profile" or "direct reports" because those depend on the relationship between the user and the specific resource.
ABAC Policies
An ABAC policy evaluates attributes at request time:
{
"policy": "edit_profile",
"effect": "allow",
"conditions": {
"subject.user_id": { "equals": "resource.owner_id" },
"action": { "equals": "update" },
"resource.type": { "equals": "user_profile" }
}
}
A more complex example:
{
"policy": "approve_expense",
"effect": "allow",
"conditions": {
"subject.role": { "equals": "manager" },
"subject.department": { "equals": "resource.department" },
"resource.amount": { "less_than": 10000 },
"environment.time": { "between": ["09:00", "17:00"] }
}
}
Combining RBAC & ABAC
In practice, most APIs use RBAC as the baseline and add ABAC rules for specific cases:
1. Check role-based permissions (fast, cached)
2. If role check passes, evaluate ABAC policies (slower, contextual)
3. Both must pass for access to be granted
Google Cloud IAM follows this hybrid model. Roles grant broad permissions, and IAM conditions (ABAC) add fine-grained constraints like "only resources with tag:production" or "only during maintenance windows."
The Principle of Least Privilege
Every token, user, and service should have the minimum permissions needed to perform its function. This is not just a best practice — it is a damage-control strategy.
Applying Least Privilege
For tokens: Request only the scopes you need. A reporting dashboard should request read:reports, not admin:everything.
For service accounts: A billing service does not need access to user profiles. Create separate service accounts with narrow permissions.
For roles: Start with the most restrictive role and add permissions as needed. Do not default to admin.
For API keys: Stripe lets you create restricted keys with specific permissions:
{
"name": "reporting_key",
"permissions": {
"charges": "read",
"balance": "read",
"customers": "none",
"refunds": "none"
}
}
Blast Radius
When a token is compromised, least privilege limits the damage. A token with read:orders can leak order data but cannot delete users, modify billing, or access other resources. A token with full admin access can do anything.
Token Introspection
For opaque tokens (not JWTs), the resource server needs to ask the authorization server what permissions the token carries.
OAuth 2.0 Token Introspection (RFC 7662)
POST /oauth/introspect
Content-Type: application/x-www-form-urlencoded
token=dGhpcyBpcyBhbiBvcGFxdWUgdG9rZW4...
{
"active": true,
"sub": "user_123",
"client_id": "app_456",
"scope": "read:users write:orders",
"exp": 1711036800,
"iat": 1711033200,
"iss": "https://auth.example.com",
"token_type": "Bearer"
}
If the token is revoked or expired:
{
"active": false
}
Caching Introspection Results
Introspecting every request creates a bottleneck at the auth server. Cache introspection results with a TTL shorter than the token's remaining lifetime. For a token expiring in 15 minutes, cache the result for 5 minutes.
The tradeoff: caching delays revocation. A revoked token may still be accepted for up to one cache TTL. For most APIs, a 1-5 minute delay is acceptable. For high-security APIs (banking, healthcare), use shorter cache TTLs or switch to JWTs with short expiration.
Common Pitfalls
Using overly broad scopes. A single admin scope that grants everything defeats the purpose. Define granular scopes at the resource level: read:users, write:users, delete:users.
Hard-coding role checks instead of permission checks. Checking if role == "admin" bakes the authorization model into the code. Check if has_permission("delete:users") instead — then you can change which roles have that permission without changing code.
Not distinguishing scope limitations from role limitations. A user with admin role but a token with only read:users scope should not be able to write users. The effective permissions are the intersection of the user's permissions and the token's scopes.
Creating too many roles. If you have 50 roles with subtle differences, nobody can understand the permission model. Start with 3-5 roles and use ABAC for edge cases.
Forgetting to check resource ownership. RBAC says "this user can edit profiles." But which profiles? Without an ownership check (ABAC), a user with write:users could edit anyone's profile. Always verify the relationship between the user and the specific resource.
Key Takeaways
- Scopes limit what a token can do, independent of the user's full permissions; request only what you need.
- RBAC assigns permissions through named roles; keep the role hierarchy shallow and the role count small.
- ABAC handles complex authorization rules that depend on relationships between users, resources, and context.
- Effective permissions are the intersection of the user's role permissions and the token's granted scopes.
- Apply the principle of least privilege at every level: tokens, service accounts, roles, and API keys to limit the blast radius of any compromise.