In this workshop, you will use Microsoft Entra ID as the authorization server and a Go REST API as the resource server to understand OAuth 2.0 / OpenID Connect in real-world scenarios.
This guide covers two main patterns:
- A. M2M (Machine-to-Machine): An Azure workload calls the API using its Managed Identity.
- B. User-Delegated Flow: A client such as React or Postman signs in a user and calls the API on that user's behalf.
Note: App registrations and API exposure settings are primarily managed in the Microsoft Entra admin center (
entra.microsoft.com). Enabling Managed Identity is done on the Azure resource side, usually via the Azure Portal (portal.azure.com).
Is a user involved?
│
├─ YES → B. User-Delegated Flow
│ (SPA, mobile apps, or Postman acting as a user)
│
└─ NO → Is it running on Azure?
│
├─ YES → A. M2M (Managed Identity recommended)
│ (App Service, Functions, batch jobs on VM, etc.)
│
└─ NO → M2M (Client Credentials + Secret/Certificate)
(On-premises, connections from other clouds)
You will need the following values during configuration. It is helpful to note them down beforehand.
| Value | Where to find | Where it's used |
|---|---|---|
| Tenant ID | Entra admin center → Overview | Go code environment variable TENANT_ID |
| API App Client ID | App registrations → workshop-api → Overview | Go code environment variable API_CLIENT_ID, Token requests |
| Client App Client ID | App registrations → workshop-client → Overview | Postman / SPA configuration |
| Managed Identity Object ID | Azure Portal → Resource → Identity → Object ID | App Role assignment (az rest command) |
Note: Client ID is also referred to as "Application (client) ID". It is a string in GUID format.
| Item | A. M2M | B. User-Delegated Flow |
|---|---|---|
| Subject | Application / Workload | User |
| Typical Examples | App Service, Functions, VM, Batch jobs | SPA, Mobile, Desktop, Postman |
| Auth Flow | Client Credentials Flow | Authorization Code Flow + PKCE |
| API Side Permissions | App Role | Scope |
| Token Claim | roles: ["Svc.Invoke"] |
scp: "access_as_user" |
| Managed Identity | Actively used (within Azure) | Normally not used |
| Token Expiration | Typically longer (~60 min) | Typically shorter (~5-60 min) |
Key Difference:
- M2M: The API validates the
rolesclaim in the token.- User-Delegated: The API validates the
scp(scope) claim in the token.
Entra ID issues two types of access token formats. This workshop uses v2 tokens.
| Item | v1 Token | v2 Token |
|---|---|---|
aud (audience) |
api://<client-id> (URI format) |
<client-id> (GUID format) |
iss (issuer) |
https://sts.windows.net/<tenant-id>/ |
https://login.microsoftonline.com/<tenant-id>/v2.0 |
| Validation complexity | Requires URI matching | Simple GUID matching |
Why we recommend v2: It simplifies the validation logic and reduces implementation errors.
Setting: App registrations → Target App → Manifest →
"requestedAccessTokenVersion": 2
To verify your configuration, decode the acquired token and inspect its contents.
- Copy the token to your clipboard.
- Visit https://jwt.ms.
- Paste the token to see the decoded Claims.
# Decode token (without signature validation)
echo "<TOKEN>" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .| Claim | Expected Value | Description |
|---|---|---|
aud |
API Client ID | Is it intended for your API? |
iss |
https://login.microsoftonline.com/<tenant-id>/v2.0 |
Is it from the correct tenant? |
scp |
access_as_user |
For user-delegated flows |
roles |
["Svc.Invoke"] |
For M2M flows |
appid |
Client App ID | Which app requested the token? |
| Actor | Example | Role |
|---|---|---|
| Resource Owner | User | Signs in and grants consent |
| Client | React / Postman / Azure Workload | Obtains an access token and calls the API |
| Authorization Server | Microsoft Entra ID | Authenticates and issues access tokens |
| Resource Server | Go REST API | Validates tokens and returns resources |
This pattern is for service-to-service communication without user intervention. When calling an API from an Azure workload, Managed Identity + App Role is the standard approach.
sequenceDiagram
participant Workload as Azure Workload<br/>(App Service, etc.)
participant MI as Managed Identity Endpoint<br/>(IMDS / Local Endpoint)
participant Entra as Microsoft Entra ID
participant API as Go Resource Server
Note over Workload, Entra: 1. Workload gets a token using Managed Identity
Workload->>MI: Request token via SDK or endpoint
MI->>Entra: Backend token acquisition
Entra-->>Workload: Access token (roles: ["Svc.Invoke"])
Note over Workload, API: 2. API Call
Workload->>API: Authorization: Bearer <JWT>
API->>API: Validate iss / aud / roles / signature
API-->>Workload: 200 OK
- Microsoft Entra admin center →
App registrations→New registration - Name:
workshop-api - After creation, note the
Application (client) ID(used later). Expose an API→Setto configure the Application ID URI.- Example:
api://<API_APP_CLIENT_ID> -
Important: This URI is used as the "resource identifier" during token requests. However, once the v2 token setting is enabled as described below, the actual
aud(audience) claim in the token will contain the Client ID (GUID), not the URI.
- Example:
App registrations→workshop-api→App roles→Create app role- Display name:
Svc.Invoke - Allowed member types:
Applications - Value:
Svc.Invoke - Add a description if needed, then save.
By default, v1 tokens are issued. To use v2 tokens, follow these steps:
- Open
Manifest. - Set
requestedAccessTokenVersionto2inside theapisection.
"api": {
"requestedAccessTokenVersion": 2
}Why v2: In v2 tokens,
audbecomes the API's client ID (GUID), simplifying the validation logic.Note: Previously this was named
accessTokenAcceptedVersion, but nowrequestedAccessTokenVersionis used.
This example shows a Go implementation for an App Service (Client) that retrieves a token using Managed Identity and calls another API (Resource).
Use azidentity from the Azure SDK for Go.
go get github.com/Azure/azure-sdk-for-go/sdk/azidentity
go get github.com/Azure/azure-sdk-for-go/sdk/azcoreUsing azidentity.NewDefaultAzureCredential allows the code to work seamlessly in both local environments (logged in via Azure CLI) and Azure environments (Managed Identity). This example implements a web server to display the results in a browser.
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
)
func main() {
// Get port from environment variable (App Service default is 8080)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// 1. Authenticate using Managed Identity
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
http.Error(w, "Failed to create credential: "+err.Error(), 500)
return
}
// 2. Attempt to acquire a token
scope := os.Getenv("API_SCOPE")
if scope == "" {
fmt.Fprintln(w, "Warning: API_SCOPE is not set. Using default...")
scope = "https://graph.microsoft.com/.default" // For testing
}
token, err := cred.GetToken(context.Background(), policy.TokenRequestOptions{
Scopes: []string{scope},
})
if err != nil {
// Display error in browser on failure
fmt.Fprintf(w, "❌ Token Error: %v\n", err)
log.Printf("Token Error: %v", err)
return
}
// 3. Display success message in browser
fmt.Fprintf(w, "✅ Managed Identity Success!\n")
fmt.Fprintf(w, "Token (first 10 chars): %s...\n", token.Token[:10])
fmt.Fprintf(w, "Expires On: %v\n\n", token.ExpiresOn)
log.Printf("Successfully retrieved token for scope: %s", scope)
// 4. Call the API Endpoint
apiEndpoint := os.Getenv("API_ENDPOINT")
if apiEndpoint == "" {
fmt.Fprintln(w, "⚠️ API_ENDPOINT is not set. Skipping API call.")
return
}
client := &http.Client{}
req, err := http.NewRequest("GET", apiEndpoint, nil)
if err != nil {
fmt.Fprintf(w, "❌ Failed to create API request: %v\n", err)
return
}
req.Header.Set("Authorization", "Bearer "+token.Token)
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(w, "❌ API Call Failed: %v\n", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
fmt.Fprintf(w, "✅ API Call Success! (Status: %s)\n", resp.Status)
} else {
fmt.Fprintf(w, "⚠️ API Call Failed (Status: %s)\n", resp.Status)
}
fmt.Fprintf(w, "Response Body:\n%s\n", string(body))
})
log.Printf("Starting server on port %s...", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}- Behavior on App Service: After the container starts, accessing
http://<your-app-name>.azurewebsites.net/will trigger token retrieval using Managed Identity and display the result of the API call.
| Variable | Required | Description | Default |
|---|---|---|---|
PORT |
- | Listening port number | 8080 |
API_SCOPE |
- | The scope of the API you want to access | https://graph.microsoft.com/.default (for testing) |
API_ENDPOINT |
- | The URL of the API to call | - (skips API call if not set) |
Note: Typically,
API_SCOPEis set toapi://<api-client-id>/.default. The.defaultsuffix means "all permissions assigned to that API".
- API-side Validation: As mentioned before, the API server (Resource Server) must validate that the
audmatches its own Client ID.
- Azure Portal → Target Resource (App Service, etc.) →
Identity. - Enable
System assignedorUser assigned. - After enabling, note the principal object ID of the Managed Identity.
Assigning an App Role to a Managed Identity is more reliable via Microsoft Graph / Azure CLI than the UI.
# ============================================
# Preparation: Obtain IDs
# ============================================
# API app's Client ID (from App registrations Overview)
# Example: 11111111-2222-3333-4444-555555555555
API_APP_CLIENT_ID=<API_APP_CLIENT_ID>
# Managed Identity's Object ID
# Find in Azure Portal → Resource → Identity → Object ID
# This functions as the Service Principal's Object ID
MI_SP_OBJECT_ID=<MANAGED_IDENTITY_OBJECT_ID>
# Get API app's service principal object ID
API_SP_OBJECT_ID=$(az ad sp show --id ${API_APP_CLIENT_ID} --query id -o tsv)
# Get the App Role ID exposed by the API
APP_ROLE_ID=$(az ad sp show --id ${API_APP_CLIENT_ID} \
--query "appRoles[?value=='Svc.Invoke'].id | [0]" -o tsv)
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/${MI_SP_OBJECT_ID}/appRoleAssignments" \
--body "{
\"principalId\": \"${MI_SP_OBJECT_ID}\",
\"resourceId\": \"${API_SP_OBJECT_ID}\",
\"appRoleId\": \"${APP_ROLE_ID}\"
}"Important: When a Managed Identity acquires a token, it usually doesn't hit Entra's
/tokendirectly. It uses the Azure Identity SDK or the local endpoint for Managed Identity.
This pattern is for SPAs or Postman calling an API after signing in a user. The current recommendation is Authorization Code Flow + PKCE.
sequenceDiagram
participant User as User
participant Client as Client App<br/>(React / Postman)
participant Entra as Microsoft Entra ID
participant API as Go Resource Server
Note over User, Entra: 1. Sign-in and Consent
User->>Entra: Sign in
Entra-->>Client: Authorization Code
Note over Client, Entra: 2. Token exchange with PKCE
Client->>Entra: code + code_verifier
Entra-->>Client: Access Token (scp: "access_as_user")
Note over Client, API: 3. API Call
Client->>API: Authorization: Bearer <JWT>
API->>API: Validate iss / aud / scp / signature
API-->>Client: 200 OK
- Microsoft Entra admin center →
App registrations→workshop-api. Expose an API→Add a scope.- Scope name:
access_as_user. - Who can consent:
Admins and users. - Fill in Admin consent and User consent display names, then save.
App registrations→New registration→ Name:workshop-client.Authentication→Add a platform.- For browser-based SPAs like React:
Single-page application. - For Postman or native apps:
Mobile and desktop applications.
- For browser-based SPAs like React:
- Register the Redirect URI (e.g.,
http://localhost:3000orhttps://oauth.pstmn.io/v1/callback).
Important: For SPAs, make sure to select
Single-page applicationin the platform configuration. SelectingWebwill cause CORS errors during token exchange.
workshop-client→API permissions→Add a permission.My APIs→workshop-api.- Add
Delegated permissions→access_as_user.
| Consent Type | Required When | Performer |
|---|---|---|
| User Consent | Tenant allows user consent + Low impact permissions | The user signing in |
| Admin Consent | User consent disabled / High impact / Application permissions | Tenant Admin |
Operation:
- User Consent: A consent screen is shown during the first sign-in.
- Admin Consent: Click
API permissions→Grant admin consent for [Tenant Name].- Required if tenant policy prohibits user consent or for Application permissions (M2M).
Below is a minimal example using github.com/MicahParks/keyfunc/v3 and github.com/golang-jwt/jwt/v5. It reads settings from environment variables and runs on port 8080 (App Service default).
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
func main() {
// Read settings from environment variables
tenantID := os.Getenv("TENANT_ID")
apiClientID := os.Getenv("API_CLIENT_ID")
requiredScope := os.Getenv("REQUIRED_SCOPE")
if requiredScope == "" {
requiredScope = "access_as_user"
}
requiredAppRole := os.Getenv("REQUIRED_APP_ROLE")
if requiredAppRole == "" {
requiredAppRole = "Svc.Invoke"
}
if tenantID == "" || apiClientID == "" {
log.Fatal("Error: TENANT_ID and API_CLIENT_ID must be set")
}
jwksURL := fmt.Sprintf("https://login.microsoftonline.com/%s/discovery/v2.0/keys", tenantID)
expectedIssuer := fmt.Sprintf("https://login.microsoftonline.com/%s/v2.0", tenantID)
// Fetch JWKS for token validation
kf, err := keyfunc.NewDefault([]string{jwksURL})
if err != nil {
log.Fatalf("failed to create keyfunc: %v", err)
}
http.HandleFunc("/api/profile", func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
// Validate JWT signature, Issuer, and Audience
token, err := jwt.Parse(tokenStr, kf.Keyfunc,
jwt.WithIssuer(expectedIssuer),
jwt.WithAudience(apiClientID),
jwt.WithValidMethods([]string{"RS256"}),
)
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid claims", http.StatusUnauthorized)
return
}
// Validate App Role (for M2M flow)
hasValidRole := false
if rawRoles, ok := claims["roles"].([]any); ok {
for _, r := range rawRoles {
if role, ok := r.(string); ok && role == requiredAppRole {
hasValidRole = true
break
}
}
}
// Validate Scope (for user-delegated flow)
scp, _ := claims["scp"].(string)
hasValidScope := false
for _, s := range strings.Fields(scp) {
if s == requiredScope {
hasValidScope = true
break
}
}
if !hasValidScope && !hasValidRole {
http.Error(w, "Forbidden: insufficient permissions", http.StatusForbidden)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"user": claims["name"],
"claims": claims,
})
})
// Use PORT environment variable (default is 8080)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Starting API server on port %s...", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}audValidation: Match against the actualaudclaim in the token. For v2 tokens, this is the API's Client ID (GUID).- Meaning of
scpandaccess_as_user:access_as_useris a Scope used only in the "User-Delegated Flow (Pattern B)".- It represents the permission for the app to act on behalf of the signed-in user. You create this in the "Expose an API" menu in Entra ID.
- Permissions in M2M Flow:
- The
scp(Scope) claim is NOT used in the M2M Flow (Pattern A). Instead,roles(App Role) is used. - In Managed Identity communication, the
scpclaim is typically missing, and the assigned App Role appears in therolesclaim.
- The
- Validation Logic: The Go code above allows access if either
hasValidScope(for users) orhasValidRole(for M2M) is true. - Environment Variables: Set the following in your App Service configuration:
| Variable | Required | Description | Default |
|---|---|---|---|
TENANT_ID |
✅ | Entra Tenant ID | - |
API_CLIENT_ID |
✅ | API App Client ID | - |
REQUIRED_SCOPE |
- | Scope required for user flow | access_as_user |
REQUIRED_APP_ROLE |
- | App Role required for M2M flow | Svc.Invoke |
PORT |
- | Listening port number | 8080 |
# Passing constants via environment variables
TENANT_ID=<tenant-id> \
API_CLIENT_ID=<api-client-id> \
go run main.go- Postman → Authorization tab.
- Type:
OAuth 2.0. - Grant type:
Authorization Code (With PKCE). - Callback URL:
https://oauth.pstmn.io/v1/callback. - Auth URL:
https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize. - Access Token URL:
https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token. - Client ID:
<client-app-id>. - Scope:
api://<api-client-id>/access_as_user. - Code Challenge Method:
S256. - "Get New Access Token" → After sign-in, it's automatically attached to requests.
# Call the API with the acquired token
curl -H "Authorization: Bearer <token>" http://localhost:8081/api/profile# To emulate Managed Identity locally for development, use a Service Principal
az login --service-principal \
-u <client-id> \
-p <client-secret> \
--tenant <tenant-id>
# Get access token
TOKEN=$(az account get-access-token \
--resource api://<api-client-id> \
--query accessToken -o tsv)
# API call
curl -H "Authorization: Bearer $TOKEN" http://localhost:8081/api/profile| Error | Verification Command | Action |
|---|---|---|
401 Unauthorized |
Check token on jwt.ms | Does aud match the API's Client ID? |
403 Forbidden |
Check scp / roles |
Is the Scope or App Role granted? |
invalid_client |
Check Client ID / Secret | Are you using the correct App Registration? |
| Symptom | Cause | Solution |
|---|---|---|
401 Unauthorized / aud mismatch |
Token not intended for this API | Decode the token at jwt.ms and verify aud matches the API's Client ID. |
| 403 Forbidden | Signature valid but missing permissions | Verify scp / roles, consent status, and App Role assignment. |
| CORS error in SPA token exchange | Redirect URI platform misconfiguration | Ensure platform is set to Single-page application, not Web. |
roles missing |
Using user flow / Assignment skipped | For M2M, use Client Credentials. Double-check App Role assignment (az rest steps). |
iss mismatch |
Wrong tenant / Mix of v1 and v2 | Verify requestedAccessTokenVersion and Resource Server issuer settings. |
ManagedIdentityCredential: managed identity timed out |
No App Role assigned to API | Expected behavior if no role is assigned. Entra ID denies the token request, causing the SDK to timeout. Assign the App Role to the Managed Identity. |
If you encounter token validation errors, follow these steps:
# Recommended: Check on jwt.ms
# 1. Copy token
# 2. Go to https://jwt.ms
# 3. Paste and verify
# Or use jq
echo "<TOKEN>" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .{
"aud": "11111111-2222-3333-4444-555555555555", // Matches API Client ID
"iss": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"scp": "access_as_user", // For user-delegated flow
"roles": ["Svc.Invoke"], // For M2M flow
"appid": "client-app-id" // The requesting application ID
}| Mistake | Correct Configuration |
|---|---|
aud is api://xxx (URI format) |
Should be Client ID (GUID) for v2 tokens |
iss is sts.windows.net |
Should be login.microsoftonline.com/.../v2.0 for v2 |
Checking scp in M2M flow |
Check roles in M2M |
Checking roles in user flow |
Check scp in user-delegated flow |
Display on Success: Once permissions are correctly assigned and the API call succeeds, the browser will display:
✅ Managed Identity Success!
Token (first 10 chars): eyJ0eXAiOi...
Expires On: 2026-03-23 ...
✅ API Call Success! (Status: 200 OK)
Response Body:
{"claims":{..., "roles":["Svc.Invoke"], ...}, "status":"ok", ...}
Display when App Role is NOT assigned: The token can be obtained from Entra ID, but if the API rejects it, you will see the following (note that token acquisition itself may timeout if the Managed Identity has no App Role assigned to any API):
✅ Managed Identity Success!
Token (first 10 chars): eyJ0eXAiOi...
Expires On: 2026-03-23 ...
⚠️ API Call Failed (Status: 403 Forbidden)
Response Body:
Forbidden: insufficient permissions
Display when Managed Identity is not enabled or Entra ID denies token issuance: If there is an issue with the Managed Identity configuration itself, an error like this will appear:
❌ Token Error: DefaultAzureCredential: failed to acquire a token.
Attempted credentials:
EnvironmentCredential: missing environment variable AZURE_TENANT_ID
WorkloadIdentityCredential: no client ID specified. Check pod configuration or set ClientID in the options
ManagedIdentityCredential: managed identity timed out. See https://aka.ms/azsdk/go/identity/troubleshoot#dac for more information
AzureCLICredential: Azure CLI not found on path
AzureDeveloperCLICredential: Azure Developer CLI not found on path
- M2M: Use
Managed Identity + App Role. - User-Delegated: Use
Authorization Code + PKCE + Scope. - App registration UI is mainly in the Microsoft Entra admin center.
- Enabling Managed Identity is in the Azure Portal.
- API side must validate
iss,aud,scp/roles, and signature separately.
- Register API App and set Application ID URI.
- Create App Role (
Svc.Invoke). - Set
requestedAccessTokenVersion: 2in Manifest. - Enable Managed Identity on the Azure resource.
- Assign App Role to Managed Identity (
az rest). - Validate
rolesin the API server.
- Expose Scope (
access_as_user) in the API App. - Register Client App (choose
Single-page applicationfor SPAs). - Register Redirect URI.
- Add API Permission to the Client App.
- Grant Admin Consent (if required).
- Validate
scpin the API server.