Microsoft Entra ID skill for Codex

Right OAuth flow, zero client secrets. Codex picks the OAuth flow from the app type, registers APIs with v2 tokens, scopes and app roles, and validates issuer, audience, tenant and scope or role on every request. Workloads in Azure get managed identities, CI gets federated credentials with exact subjects, and tenant-wide actions like admin consent or credential resets wait for confirmation.

  • Cloud Platforms
  • $azure-entra-id
  • 191 lines
  • 3 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- azure-entra-id

Installs to ~/.agents/skills/azure-entra-id/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $azure-entra-id, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Configures Microsoft Entra ID (formerly Azure AD) authentication - app registrations and service principals, MSAL sign-in for SPAs, web apps and daemons, exposed API scopes and app roles, access token validation, admin consent, managed identities with DefaultAzureCredential, and federated identity credentials for GitHub Actions instead of client secrets. Use when adding Microsoft sign-in, protecting an API with Entra tokens, choosing an OAuth flow, debugging AADSTS errors or 401s, or removing client secrets from code and CI.

Use it when

  • Adding Microsoft or work-account sign-in to an app
  • Protecting an API with Entra ID access tokens
  • Debugging AADSTS errors or unexpected 401 and 403 responses
  • Removing client secrets from code, config or CI

Use something else when

  • You need provider-neutral OAuth or OIDC design: use oauth2-oidc
  • You are reviewing an existing OAuth implementation for vulnerabilities: use oauth-security-review

How to check the result

A real token decodes with the expected aud, iss, tid and scp or roles, and the API returns 401 for missing, expired or wrong-audience tokens and 403 for a missing scope.

The mistake it prevents

Accepting ID tokens or Graph access tokens at your API, or keying users on email instead of tid plus oid.

~/.agents/skills/azure-entra-id/SKILL.md

--- name: azure-entra-id description: Configures Microsoft Entra ID (formerly Azure AD) authentication - app registrations and service principals, MSAL sign-in for SPAs, web apps and… license: MIT ---

Done well means: each app uses the OAuth flow that matches its type, APIs validate issuer, audience, tenant and scope or role on every request, workloads in Azure use managed identities and CI uses federated credentials so no client secret exists to leak, and every permission requested is the least one that works.

Vocabulary that causes most bugs

TermMeaning
App registration (application object)The global definition: client ID, redirect URIs, exposed scopes, roles
Enterprise application (service principal)The per-tenant instance; holds consent, role assignments, sign-in logs
Client ID / Application IDGUID of the app registration; the aud of v2 access tokens for your API
Tenant IDDirectory GUID; appears as tid in tokens
Delegated permission (scope)App acts for a signed-in user; appears in scp
Application permission (app role)App acts as itself; appears in roles; always needs admin consent

Inspect first

  1. Read AGENTS.md and the auth configuration in code (msal config, appsettings.json AzureAd section, env vars). Never print client secrets or certificates.
  2. Inspect the registration (needs network and az login with directory read access):
az ad app show --id "$CLIENT_ID" \
  --query "{name:displayName, audience:signInAudience, spa:spa.redirectUris, web:web.redirectUris, tokenVersion:api.requestedAccessTokenVersion, scopes:api.oauth2PermissionScopes[].value, roles:appRoles[].value}"
az ad app credential list --id "$CLIENT_ID" --query "[].{name:displayName, end:endDateTime}"
az ad app federated-credential list --id "$CLIENT_ID"
az ad sp show --id "$CLIENT_ID" --query "{appRoleAssignmentRequired:appRoleAssignmentRequired}"

Pick the flow

App typeFlowLibraryCredential
SPA (React, Angular, Vue)Authorization code + PKCE@azure/msal-browser / msal-reactNone (public client)
Server-rendered web appAuthorization codeMicrosoft.Identity.Web, msal-node, msal (Python)Certificate or federated credential
Web API called by your appsValidate bearer tokensMicrosoft.Identity.Web, jose/JWKS, msal not neededNone
API calling another API for the userOn-behalf-ofMSAL confidential clientCertificate
Daemon, job, service-to-serviceClient credentials with .defaultMSAL or Azure IdentityManaged identity, then federated credential, then certificate
CLI or device without a browserDevice codeMSAL public clientNone
Workload running in Azure calling Azure servicesManaged identityAzure Identity DefaultAzureCredentialNone

Never use the implicit flow or resource owner password credentials for new work.

Register an API and a client

# API
az ad app create --display-name "orders-api" --sign-in-audience AzureADMyOrg
API_ID=$(az ad app list --display-name orders-api --query "[0].appId" -o tsv)
az ad app update --id "$API_ID" --identifier-uris "api://$API_ID"
az ad sp create --id "$API_ID"

Set in the API manifest (portal Manifest blade or az rest against Microsoft Graph):

  • api.requestedAccessTokenVersion: 2 so tokens use the v2 issuer and aud = client ID.
  • A delegated scope such as Orders.Read and app roles such as Orders.Process with allowedMemberTypes: ["Application"] for daemons.
  • On the enterprise app, appRoleAssignmentRequired: true if only assigned users/apps may get tokens.

Client SPA request: scope api://<API_ID>/Orders.Read. Daemon request: api://<API_ID>/.default, which returns whatever app roles were admin-consented.

Granting admin consent (az ad app permission admin-consent --id <client>) affects the whole tenant. Ask the user before running it.

SPA sign-in (msal-browser)

import { PublicClientApplication, InteractionRequiredAuthError } from "@azure/msal-browser";

export const msal = new PublicClientApplication({
  auth: {
    clientId: import.meta.env.VITE_ENTRA_CLIENT_ID,
    authority: `https://login.microsoftonline.com/${import.meta.env.VITE_ENTRA_TENANT_ID}`,
    redirectUri: "/auth/callback",
  },
  cache: { cacheLocation: "sessionStorage" },
});
await msal.initialize();
await msal.handleRedirectPromise();

export async function apiToken(): Promise<string> {
  const account = msal.getAllAccounts()[0];
  const request = { scopes: ["api://<API_ID>/Orders.Read"], account };
  try {
    return (await msal.acquireTokenSilent(request)).accessToken;
  } catch (e) {
    if (e instanceof InteractionRequiredAuthError) await msal.acquireTokenRedirect(request);
    throw e;
  }
}

Register SPA redirect URIs under the spa platform, not web; the wrong platform causes AADSTS9002326 (cross-origin token redemption) errors.

Protecting an API

Validate every request. Required checks: signature against tenant JWKS, iss, aud, exp/nbf, tid (multi-tenant allow-list), and scp or roles for the operation. For implementation details, library choices per language and the multi-tenant issuer rules, read references/token-validation.md when writing or reviewing API auth code.

Hard rules:

  • An ID token proves sign-in to the client; it is never accepted by an API.
  • Tokens for Microsoft Graph (aud = 00000003-0000-0000-c000-000000000000) are not for your API and cannot be validated by you; reject them.
  • Authorise on oid (+ tid) as the stable user key, not email, upn or preferred_username, which are mutable and sometimes user-controlled.

Managed identity and Azure SDKs

from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient

cred = DefaultAzureCredential()   # managed identity in Azure, az login / VS Code locally
blobs = BlobServiceClient("https://shopst.blob.core.windows.net", credential=cred)
  • Use a user-assigned identity when several resources share access or the identity must survive resource recreation; set AZURE_CLIENT_ID so the SDK picks the right one.
  • Managed identities need data-plane roles (Storage Blob Data Reader, Key Vault Secrets User).
  • In production, consider ManagedIdentityCredential directly; DefaultAzureCredential probes several sources and can pick up an unexpected developer credential.

GitHub Actions without secrets

az ad app federated-credential create --id "$DEPLOY_APP_ID" --parameters '{
  "name": "gh-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:my-org/shop:environment:production",
  "audiences": ["api://AzureADTokenExchange"]
}'

The subject must match exactly (repo:ORG/REPO:ref:refs/heads/main, ...:environment:NAME, or ...:pull_request). Wildcards are not supported in standard federated credentials. Then use azure/login@v2 with client-id, tenant-id, subscription-id and permissions: id-token: write.

Secret hygiene

  • Prefer certificates over client secrets, and federated credentials or managed identities over both.
  • az ad app credential reset replaces existing credentials unless --append is set; running it on a production app breaks every deployed instance. Confirm first.
  • Track expiry dates; expired secrets are a top cause of sudden AADSTS7000222 outages.

Common AADSTS errors

CodeMeaningFix
AADSTS50011Redirect URI mismatchRegister the exact URI, including path and trailing slash
AADSTS65001Consent missingUser or admin consent for the scope
AADSTS700016App not found in tenantWrong tenant/authority, or single-tenant app used cross-tenant
AADSTS7000215Invalid client secretUsing secret ID instead of value, or expired
AADSTS70011Invalid scopeDaemon must use /.default; scope string typo
AADSTS50105User not assignedAssignment required on the enterprise app

Verification

  • Decode a test access token (locally, never on third-party sites for production tokens) and confirm aud, iss (v2 form), tid, and scp/roles.
  • Call the API with: a valid token (200), no token (401), a token for another audience (401), a token missing the scope/role (403).
  • az ad app credential list shows no secrets for apps moved to federated credentials.

Report template

**Apps:** orders-api (single tenant, v2 tokens, scope Orders.Read, role Orders.Process), orders-spa (spa platform)
**Flows:** SPA auth code + PKCE; nightly job client credentials via managed identity
**API checks:** iss, aud, tid, exp, scp/roles enforced; 4 negative tests pass
**Secrets:** 0 client secrets; CI uses federated credential repo:my-org/shop:environment:production
**Consent:** admin consent for Orders.Process granted by user on 2026-11-04

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Microsoft Entra ID"
  short_description: "Right OAuth flow, zero client secrets"
  default_prompt: "$azure-entra-id Add Microsoft sign-in to our React SPA and call our API with an Entra access token"
references/token-validation.md91 lines, 4.3 KB
# Validating Entra ID access tokens in an API

Read this when writing or reviewing the code that accepts bearer tokens.

## What a valid v2 access token for your API looks like

| Claim | Expected value |
|---|---|
| `iss` | `https://login.microsoftonline.com/<tid>/v2.0` |
| `aud` | Your API's client ID (GUID). v1 tokens carry `api://<id>` instead |
| `tid` | Your tenant, or a tenant on your allow-list (multi-tenant APIs) |
| `exp`, `nbf` | Current time inside the window (allow about 5 minutes of clock skew) |
| `scp` | Space-separated delegated scopes, e.g. `Orders.Read` (user tokens) |
| `roles` | Array of app roles, e.g. `["Orders.Process"]` (app tokens, or user role assignments) |
| `oid` | Object ID of the user or service principal: the stable subject key |
| `azp` | Client ID of the calling app (useful to allow-list callers) |
| `ver` | `2.0` |

If `ver` is `1.0` and `iss` is `https://sts.windows.net/<tid>/`, the API's manifest still has
`requestedAccessTokenVersion` null or 1. Fix the manifest rather than accepting both
issuers unless old clients force it.

## Keys

- Metadata: `https://login.microsoftonline.com/<tid>/v2.0/.well-known/openid-configuration`
- JWKS: the `jwks_uri` from that document (`https://login.microsoftonline.com/<tid>/discovery/v2.0/keys`).
- Cache keys, refresh on unknown `kid`, and never disable signature checks in any
  environment. Keys rotate without notice.

## Delegated vs application tokens

A token with `scp` comes from a user flow; a token with `roles` and no `scp` comes from client
credentials. Decide per endpoint which kinds are allowed. A common bug is an endpoint that
checks only `scp`, so app-only tokens without `scp` skip the check and fall through to
"allowed" in permissive code. Write the check as: allowed if (`scp` contains X) or (`roles`
contains Y); reject otherwise.

## Multi-tenant APIs

- Authority for clients: `organizations` or `common`.
- The issuer differs per tenant, so do not compare `iss` to a fixed string. Validate that
  `iss` equals `https://login.microsoftonline.com/{tid}/v2.0` with the token's own `tid`,
  and that `tid` is in your allow-list of onboarded customers.
- Without the allow-list, any Entra tenant in the world can obtain a token for your API once
  its admin consents.

## Libraries

| Stack | Default |
|---|---|
| ASP.NET Core | `Microsoft.Identity.Web`: `AddMicrosoftIdentityWebApi(builder.Configuration, "AzureAd")`, then `[RequiredScope]` / `[Authorize(Roles = "...")]` or policies |
| Node.js | `jose`: `createRemoteJWKSet(new URL(jwksUri))` + `jwtVerify(token, jwks, { issuer, audience })`, then check `scp`/`roles` yourself |
| Python (FastAPI, Flask) | `PyJWT` with `PyJWKClient(jwks_uri)`, `jwt.decode(token, key, algorithms=["RS256"], audience=..., issuer=...)` |
| Java Spring | `spring-cloud-azure-starter-active-directory` resource server, or Spring Security `oauth2ResourceServer().jwt()` with the issuer URI |
| Go | `github.com/coreos/go-oidc` verifier with `ClientID` set to the API client ID |

Node example:

```ts
import { createRemoteJWKSet, jwtVerify } from "jose";

const TENANT = process.env.ENTRA_TENANT_ID!;
const AUDIENCE = process.env.ENTRA_API_CLIENT_ID!;
const jwks = createRemoteJWKSet(new URL(`https://login.microsoftonline.com/${TENANT}/discovery/v2.0/keys`));

export async function authorize(header: string | undefined, need: { scope?: string; role?: string }) {
  if (!header?.startsWith("Bearer ")) throw new HttpError(401, "missing token");
  const { payload } = await jwtVerify(header.slice(7), jwks, {
    issuer: `https://login.microsoftonline.com/${TENANT}/v2.0`,
    audience: AUDIENCE,
    clockTolerance: 300,
  });
  const scopes = String(payload.scp ?? "").split(" ");
  const roles = (payload.roles as string[] | undefined) ?? [];
  const ok = (need.scope && scopes.includes(need.scope)) || (need.role && roles.includes(need.role));
  if (!ok) throw new HttpError(403, "insufficient scope or role");
  return { userId: `${payload.tid}:${payload.oid}`, appId: payload.azp };
}
```

## Negative test matrix

| Case | Expected |
|---|---|
| No `Authorization` header | 401 |
| Expired token | 401 |
| Token for Graph or another API (`aud` mismatch) | 401 |
| Token from an unlisted tenant | 401 |
| Valid token without the scope/role | 403 |
| ID token instead of access token | 401 (`aud` is the client app, not the API) |
| Valid token with the scope/role | 200 |

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem