OAuth Scopes and Fine-Grained Permissions

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

TRY NANO BANANA FOR FREE

OAuth Scopes and Fine-Grained Permissions

TRY NANO BANANA FOR FREE
Contents

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.

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 (write implies read)
  • 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