Meta Description: Design OAuth 2.0 scopes for fine-grained API access control. Learn scope naming, hierarchies, and best practices with real examples.
Keywords: oauth scopes, api permissions, access control, oauth 2.0, fine-grained permissions, scope design
Word Count: ~2,300 words
Your API uses OAuth 2.0 for authentication. Users authorize your app, and you get an access token. But that token has full access to everything.
This is dangerous. If the token leaks, attackers have complete control. Users can't grant limited access.
OAuth scopes solve this. Scopes define what an access token can do. Users grant specific permissions, not blanket access.
Here's how to design scopes correctly.
What Are OAuth Scopes?
Scopes are permission strings that define what an access token can access.
Without scopes:
Access Token → Full API access
With scopes:
Access Token (scopes: read:pets, write:pets) → Limited access
The token can only read and write pets, nothing else.
Scope Naming Conventions
Use a consistent naming pattern: action:resource
Action Verbs
read: View data (GET requests) write: Create and update data (POST, PUT, PATCH) delete: Remove data (DELETE requests) admin: Full control (all operations)
Resource Names
Use plural nouns matching your API resources: - pets- orders- users- appointments- payments
Examples
read:pets ← View pets
write:pets ← Create and update pets
delete:pets ← Delete pets
admin:pets ← Full pet management
read:orders ← View orders
write:orders ← Create and update orders
read:users ← View user profiles
write:users ← Update user profiles
Scope Hierarchies
Some scopes should imply others.
Implicit Hierarchies
write implies read:
write:pets → Can read and write pets
read:pets → Can only read pets
If you can create pets, you should be able to read them. Don't require both scopes.
admin implies everything:
admin:pets → Can read, write, and delete pets
Admin scope grants all permissions for a resource.
Implementation
function hasPermission(token, requiredScope) {
const scopes = token.scopes || [];
// Check exact match
if (scopes.includes(requiredScope)) {
return true;
}
// Check hierarchies
const [action, resource] = requiredScope.split(':');
// admin:resource implies all actions
if (scopes.includes(`admin:${resource}`)) {
return true;
}
// write:resource implies read:resource
if (action === 'read' && scopes.includes(`write:${resource}`)) {
return true;
}
return false;
}
// Usage
app.get('/v1/pets/:id', (req, res) => {
if (!hasPermission(req.token, 'read:pets')) {
return res.status(403).json({
error: 'Insufficient permissions',
required: 'read:pets'
});
}
// Handle request
});
Granular vs Coarse Scopes
Coarse Scopes (Simple)
read:all
write:all
admin:all
Pros: - Simple to understand - Easy to implement - Fewer scopes to manage
Cons: - All-or-nothing access - Can't grant limited permissions - Security risk if token leaks
Granular Scopes (Secure)
read:pets
write:pets
read:orders
write:orders
read:users
write:users
Pros: - Fine-grained control - Principle of least privilege - Limit damage from token leaks
Cons: - More complex - Users see long permission lists - More scopes to maintain
Balanced Approach
Group related resources:
read:pets ← Pets only
write:pets
read:store ← Orders, inventory, payments
write:store
read:profile ← User's own profile
write:profile
admin:all ← Full access (for admin tools)
This balances security and usability.
Scope Design Patterns
Pattern 1: Resource-Based
One scope per resource:
read:pets
write:pets
delete:pets
read:orders
write:orders
delete:orders
Use when: You have distinct resources with different sensitivity levels.
Pattern 2: Action-Based
One scope per action:
read:all
write:all
delete:all
Use when: All resources have similar sensitivity.
Pattern 3: Feature-Based
One scope per feature:
adoption:apply ← Submit adoption applications
adoption:approve ← Approve applications
adoption:manage ← Full adoption management
payments:process ← Process payments
payments:refund ← Issue refunds
payments:view ← View payment history
Use when: Features span multiple resources.
Pattern 4: Role-Based
One scope per role:
role:customer ← Customer permissions
role:staff ← Staff permissions
role:admin ← Admin permissions
Use when: You have well-defined user roles.
Implementing Scope Checks
Middleware Approach
function requireScopes(...requiredScopes) {
return (req, res, next) => {
const token = req.token;
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
const hasAllScopes = requiredScopes.every(scope =>
hasPermission(token, scope)
);
if (!hasAllScopes) {
return res.status(403).json({
error: 'Insufficient permissions',
required: requiredScopes,
granted: token.scopes
});
}
next();
};
}
// Usage
app.get('/v1/pets', requireScopes('read:pets'), async (req, res) => {
const pets = await getPets();
res.json(pets);
});
app.post('/v1/pets', requireScopes('write:pets'), async (req, res) => {
const pet = await createPet(req.body);
res.status(201).json(pet);
});
app.delete('/v1/pets/:id', requireScopes('delete:pets'), async (req, res) => {
await deletePet(req.params.id);
res.status(204).send();
});
Decorator Approach
function RequireScopes(...scopes: string[]) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const req = args[0];
const res = args[1];
if (!hasAllScopes(req.token, scopes)) {
return res.status(403).json({
error: 'Insufficient permissions',
required: scopes
});
}
return originalMethod.apply(this, args);
};
return descriptor;
};
}
// Usage
class PetController {
@RequireScopes('read:pets')
async getPets(req: Request, res: Response) {
const pets = await this.petService.getPets();
res.json(pets);
}
@RequireScopes('write:pets')
async createPet(req: Request, res: Response) {
const pet = await this.petService.createPet(req.body);
res.status(201).json(pet);
}
}
Scope Discovery
Let clients discover available scopes:
app.get('/v1/oauth/scopes', (req, res) => {
res.json({
scopes: [
{
name: 'read:pets',
description: 'View pet information',
category: 'Pets'
},
{
name: 'write:pets',
description: 'Create and update pets',
category: 'Pets',
implies: ['read:pets']
},
{
name: 'delete:pets',
description: 'Delete pets',
category: 'Pets'
},
{
name: 'read:orders',
description: 'View orders',
category: 'Store'
},
{
name: 'write:orders',
description: 'Create and update orders',
category: 'Store',
implies: ['read:orders']
}
]
});
});
This helps developers understand what scopes they need.
User Consent Screen
When users authorize your app, show clear scope descriptions:
PetStore App wants to:
✓ View your pets (read:pets)
✓ Create and update pets (write:pets)
✓ View your orders (read:orders)
[Authorize] [Cancel]
Bad descriptions:
✓ read:pets
✓ write:pets
Users don't understand technical scope names.
Good descriptions:
✓ View your pet information
✓ Add and edit pets in your account
Use plain language that explains what the app can do.
Scope Validation
Validate requested scopes during authorization:
app.post('/oauth/authorize', async (req, res) => {
const { client_id, scope, redirect_uri } = req.body;
// Parse requested scopes
const requestedScopes = scope.split(' ');
// Get client's allowed scopes
const client = await getClient(client_id);
const allowedScopes = client.allowedScopes;
// Validate scopes
const invalidScopes = requestedScopes.filter(
s => !allowedScopes.includes(s)
);
if (invalidScopes.length > 0) {
return res.status(400).json({
error: 'invalid_scope',
description: `Invalid scopes: ${invalidScopes.join(', ')}`
});
}
// Show consent screen
res.render('consent', {
client,
scopes: requestedScopes.map(s => ({
name: s,
description: getScopeDescription(s)
}))
});
});
Dynamic Scopes
Some scopes need parameters:
read:pets:123 ← Read specific pet
write:pets:123 ← Write specific pet
read:pets:* ← Read all pets
This allows per-resource permissions.
Implementation:
function matchesScope(grantedScope, requiredScope) {
// Exact match
if (grantedScope === requiredScope) {
return true;
}
// Wildcard match
const grantedPattern = grantedScope.replace(/\*/g, '.*');
const regex = new RegExp(`^${grantedPattern}$`);
return regex.test(requiredScope);
}
function hasPermission(token, requiredScope) {
return token.scopes.some(scope => matchesScope(scope, requiredScope));
}
// Usage
app.get('/v1/pets/:id', (req, res) => {
const requiredScope = `read:pets:${req.params.id}`;
if (!hasPermission(req.token, requiredScope)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
// Handle request
});
Best Practices
1. Start with coarse scopes, refine later
Begin with simple scopes like read:all and write:all. Add granular scopes as security requirements grow.
2. Document all scopes
Maintain a scope registry with descriptions, examples, and implications.
3. Use consistent naming
Stick to action:resource pattern. Don't mix patterns.
4. Implement scope hierarchies
Make write imply read. Make admin imply everything.
5. Validate scopes server-side
Never trust client-provided scopes. Always validate against the token.
6. Log scope usage
Track which scopes are used most. Remove unused scopes.
7. Version scopes carefully
Changing scope meanings breaks existing integrations. Add new scopes instead.
Common Mistakes
Mistake 1: Too many scopes
read:pets
read:pets:name
read:pets:species
read:pets:breed
...
This is overwhelming. Group related permissions.
Mistake 2: Inconsistent naming
read:pets
getPets
view_pets
Pick one pattern and stick to it.
Mistake 3: No hierarchy
Requiring both read:pets and write:pets to create pets is redundant. Make write imply read.
Mistake 4: Vague descriptions
"Access your data" doesn't tell users what the app can do. Be specific.
Conclusion
OAuth scopes enable fine-grained access control. Design them carefully:
- Use consistent naming (
action:resource) - Implement hierarchies (
writeimpliesread) - Balance granularity with usability
- Document clearly
- Validate server-side
Well-designed scopes protect users and limit damage from token leaks.
Related Articles: - API Authentication: API Keys vs OAuth vs JWT - Implementing OAuth 2.0 in Your API - JWT Best Practices for API Security