API Needs User Permissions? Expose a Delegated Scope

Published on:

APIs should not accept access tokens only because the token is valid.

They should also check what the signed-in user is allowed to do.

For user-based API access, the Microsoft Entra pattern is:

Expose a delegated scope.

This pattern is used when:

  • a user is present
  • an app calls an API on behalf of that user
  • the API needs user permission checks
  • authentication is handled by Microsoft Entra ID
  • authorization is based on the scp claim in the access token

In this example:

  • CloudTrips API is the protected resource
  • CloudTrips Client is any user-facing client app
  • Employee is the signed-in user

The client can be a web app, SPA, CLI, mobile app, or desktop app.

The important part is the same:

Client requests scope -> Entra issues access token -> API validates aud and scp

Delegated Scope vs App Role

A delegated scope represents what a client app may do on behalf of a signed-in user.

In tokens, delegated permissions appear in:

scp

Example:

{
  "scp": "Trips.Read"
}

An app role represents role-based authorization for an application, user, or group.

In tokens, app roles appear in:

roles

Use delegated scopes when the API decision is about the permission the client requested for the user.

Use app roles when the app decision is about the role assigned to the user or group.

The same signed-in user token can contain both:

{
  "scp": "Trips.Read",
  "roles": ["Trip.Approver"]
}

In that case, the two claims answer different questions:

scp

Question it answers:

Is this client allowed to call this API operation for the signed-in user?

Example use:

Allow an API read endpoint when scp contains Trips.Read.

roles

Question it answers:

What role does this user or group have inside the app?

Example use:

Show approval features when roles contains Trip.Approver.

For APIs, a common pattern is:

Check aud -> token is for this API
Check scp -> client has delegated permission
Check roles -> user has required business role, if the endpoint needs one

This trip focuses mainly on delegated scopes and the scp claim.

Create CloudTrips API App

First, create the resource application.

Go to:

Entra ID > App registrations > New registration

Create an application:

Name: CloudTrips-API
Supported account types: Single tenant

This app registration represents the API that will receive and validate access tokens.

Microsoft Entra app registration creation page with CloudTrips API entered as the application name

Set Application ID URI

Inside CloudTrips-API, open:

Expose an API

Set the Application ID URI:

api://<cloudtrips-api-application-id>

This value becomes the API audience.

The access token should later contain this value in the aud claim.

CloudTrips API Expose an API page with the Application ID URI configured

Add Delegated Scope

Inside CloudTrips-API, select:

Add a scope

Create a delegated scope:

Scope name: Trips.Read
Who can consent: Admins and users
Admin consent display name: Read CloudTrips trips
Admin consent description: Allows the app to read CloudTrips trips for the signed-in user.
User consent display name: Read your CloudTrips trips
User consent description: Allows the app to read your CloudTrips trips.
State: Enabled

This defines a user permission that client apps can request.

Add a scope form showing Trips.Read and consent display values

After saving, the scope appears under:

Scopes defined by this API

The full permission value is:

api://<cloudtrips-api-application-id>/Trips.Read

CloudTrips API Expose an API page showing the Trips.Read scope listed

Create Client App

Now create a client app that will request the scope.

Go to:

Entra ID > App registrations > New registration

Create an application:

Name: CloudTrips-Client
Supported account types: Single tenant

The exact platform depends on your app type.

For this trip, the client only needs to demonstrate that a user-facing app can request the delegated scope.

Microsoft Entra app registration creation page with CloudTrips Client entered as the application name

Add API Permission

Inside CloudTrips-Client, open:

API permissions > Add a permission

Choose:

My APIs > CloudTrips-API > Delegated permissions > Trips.Read

This allows the client app to request Trips.Read on behalf of the signed-in user.

CloudTrips Client API permissions page showing CloudTrips API delegated Trips.Read selected

If your tenant requires administrator approval, click:

Grant admin consent

After consent, the permission status should show that consent was granted for the tenant.

CloudTrips Client API permissions page showing admin consent granted for Trips.Read

Request an Access Token

The client requests an access token using the full scope value:

api://<cloudtrips-api-application-id>/Trips.Read

The exact OAuth flow depends on the client type:

Client type Common flow
Server-side web app Authorization Code Flow
SPA Authorization Code Flow with PKCE
CLI or device Device Code Flow
Mobile or desktop app Authorization Code Flow with PKCE

The important request value is the scope:

scope=openid profile api://<cloudtrips-api-application-id>/Trips.Read

Inspect Access Token

Decode the access token.

For delegated API access, check these claims:

Claim Meaning
aud API that should accept the token
scp Delegated permissions granted to the client
sub Stable user identifier for this app
tid Tenant ID
iss Token issuer

For this trip, the important proof is:

{
  "aud": "api://<cloudtrips-api-application-id>",
  "scp": "Trips.Read"
}

Validate Token in the API

The API should validate more than the token signature.

At minimum, validate:

  • signature
  • issuer
  • audience
  • expiration
  • required delegated scope

Example logic:

const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];

if (!audiences.includes('api://<cloudtrips-api-application-id>')) {
  throw new Error('Invalid audience');
}

const scopes = String(claims.scp ?? '').split(' ');

if (!scopes.includes('Trips.Read')) {
  throw new Error('Missing required scope');
}

If the token is valid and contains scp=Trips.Read, the API returns the protected data.

Compare Scopes and Roles in User Tokens

Scopes and roles are often confused because both can appear in user access tokens.

They are not the same.

scp comes from delegated API permissions.

It says what permission the client app received for this API call.

roles comes from app role assignment.

It says what role the signed-in user or group has in the application.

Use scp for API permission boundaries:

Can this client call GET /trips for this user?
Required claim: scp contains Trips.Read

Use roles for app or business authorization:

Can this user approve a trip?
Required claim: roles contains Trip.Approver

For a simple read API, scp=Trips.Read may be enough.

For a sensitive workflow, check both:

scp contains Trips.Write
roles contains Trip.Manager

The API should still reject tokens that are valid JWTs but not valid for this API.

Reject the request when:

  • aud is for another API
  • scp does not contain Trips.Read
  • the token is expired
  • the token came from the wrong tenant
  • the token signature cannot be verified

Examples:

403 Invalid audience
403 Missing required scope: Trips.Read
401 Access token is expired

Enterprise Note

Delegated scopes are part of the API contract.

Name them carefully.

Good scope names describe the API permission:

Trips.Read
Trips.Write
Trips.Approve

Avoid using broad scopes when a narrower permission is possible.

For production, document:

  • which clients may request each scope
  • whether user consent is allowed
  • when admin consent is required
  • which API endpoints require which scopes
  • how missing scopes are logged and investigated

This makes authorization easier to audit and safer to change.