Skip to content
StarLive demo →
GuideChecked against main · 31 Oct 20269 min read

Entra External ID

Set up Microsoft Entra External ID for AppointMe on Azure, covering the tenant, app registration, redirect URIs, API scope, user flow and Graph access, plus the config keys the .NET API reads in place of Keycloak.

You'll build
An External ID tenant the API trusts
Time
About 30 minutes in the portal
Touches
appsettings.Devtest.json, Key Vault
Before you start
The appServiceUrl output from Bicep

The API is a confidential OIDC client. Browsers get a cookie through the authorization code flow, API callers send a bearer token, and both trust the same External ID authority.

SPAredirects to GET /api/v1/login
External IDruns the user flow at <tenant>.ciamlogin.com
/signin-oidcthe API redeems the code with its client secret
Cookieappointme.auth, 7 days, sliding
Figure 1. Browser sign-in. A bearer token skips the redirect and is validated directly.

The API talks to External ID in four ways, and each needs something in the tenant:

What Used for Needs in the tenant Config
OIDC code flow Browser sign-in. Tokens never reach the SPA. A Web redirect URI and a client secret Authentication:EntraExternalId
JWT bearer Calls that send Authorization: Bearer An exposed API (the audience) Authority, ApiAudience
Microsoft Graph, client credentials Sign-up and team invitations: POST /invitations, plus a GET /users lookup by email Graph application permissions, with admin consent EntraIdentity
The native authentication API The demo login at /api/v1/login/demo Public client flows and native authentication turned on Authority, ClientId, Demo:User

One setting picks the provider. Authentication:Provider defaults to Keycloak, and the API rejects any value other than Keycloak or EntraExternalId:

src/AppointMe.Api/Authentication/AuthenticationExtensions.csView source ↗
var providerName = configuration["Authentication:Provider"] ?? "Keycloak";
switch (providerName)
{
case "Keycloak":
services.AddKeycloakAuthOptions(configuration);
break;
case "EntraExternalId":
services.AddEntraExternalIdAuthOptions(configuration);
break;
default:
throw new InvalidOperationException(
$"Unknown Authentication:Provider '{providerName}'. Expected 'Keycloak' or 'EntraExternalId'.");
}

You do all of this in the Microsoft Entra admin center. None of it is in Bicep, because you can only create the tenant in the portal.

  1. Create the External ID tenant. In the Azure portal go to Microsoft Entra ID → External ID → Create a new tenant, and pick a subdomain for <tenant>.ciamlogin.com. Note the tenant ID, which goes into the authority URL and EntraIdentity:TenantId.

  2. Register the app. In the new tenant open App registrations → New registration. One registration serves both the OIDC client and the API.

    Field Value
    Name For example appointme-api-devtest
    Supported account types Accounts in any organizational directory and personal Microsoft accounts, or narrower if your policy requires
    Redirect URI (Web) https://<web-app-name>.azurewebsites.net/signin-oidc

    If you add a custom domain, also register https://<your-host>/signin-oidc. The callback path is fixed in code (oidc.CallbackPath = "/signin-oidc").

  3. Leave implicit grant off. Under Authentication → Implicit grant and hybrid flows, leave both boxes unticked. The API uses the authorization code flow (OpenIdConnectResponseType.Code).

  4. Create a client secret. Under Certificates & secrets → New client secret, create one and copy the value immediately. It goes into Key Vault as EntraExternalIdClientSecret, never into a file.

  5. Expose an API. Under Expose an API → Add a scope, define a scope such as access_as_user. The Application ID URI, for example api://<client-id>, is your ApiAudience.

  6. Create a user flow. Under User flows → New user flow, create a sign-up and sign-in flow and add the app registration to it. For the demo login, the flow must sign users in with email and password.

  7. Grant Graph permissions. The Identity module creates users by sending Graph invitations. It calls Graph with the app’s own client ID and secret, using the https://graph.microsoft.com/.default scope. Under API permissions, add the Microsoft Graph application permissions User.Invite.All and User.Read.All, then grant admin consent.

  8. Turn on native authentication (demo login only). External ID doesn’t support the password grant, so the demo login uses the native authentication API instead. Under Authentication, set Enable the following mobile and desktop flows to Yes and Enable native authentication to Yes (Microsoft’s steps). Then create the demo user in the tenant with the email and password you’ll put in Demo:User.

  9. Fill in appsettings.Devtest.json. Copy the example file, then replace the placeholders with your tenant’s values.

    Terminal window
    cp src/AppointMe.Api/appsettings.Devtest.example.json src/AppointMe.Api/appsettings.Devtest.json
    src/AppointMe.Api/appsettings.Devtest.example.jsonView source ↗
    "Authentication": {
    "Provider": "EntraExternalId",
    "RequireHttpsMetadata": true,
    "SuperAdmins": [ "<super-admin-email>" ],
    "EntraExternalId": {
    "Authority": "https://<your-tenant>.ciamlogin.com/<your-tenant-id>/v2.0",
    "ClientId": "<your-api-app-client-id>",
    "ApiAudience": "api://<your-api-app-client-id>"
    }
    },
    "EntraIdentity": {
    "TenantId": "<your-tenant-id>",
    "ClientId": "<your-api-app-client-id>"
    },

    Also set Frontend:BaseUrl to the public URL and fill in the Demo block, or set Demo:Enabled to false. Leave out both client secrets: the Bicep app settings fill them from Key Vault.

Ten keys configure External ID. Two of them are secrets that come from Key Vault, and the rest are in appsettings.Devtest.json.

Key Value Set in
Authentication:Provider EntraExternalId appsettings.Devtest.json
Authentication:RequireHttpsMetadata true (also the default in code) appsettings.Devtest.json
Authentication:EntraExternalId:Authority https://<tenant>.ciamlogin.com/<tenant-id>/v2.0 appsettings.Devtest.json
Authentication:EntraExternalId:ClientId Application (client) ID appsettings.Devtest.json
Authentication:EntraExternalId:ClientSecret The client secret Key Vault: EntraExternalIdClientSecret
Authentication:EntraExternalId:ApiAudience Application ID URI, api://<client-id> appsettings.Devtest.json
EntraIdentity:TenantId Tenant ID appsettings.Devtest.json
EntraIdentity:ClientId The same client ID appsettings.Devtest.json
EntraIdentity:ClientSecret The same client secret Key Vault: EntraExternalIdClientSecret
Frontend:BaseUrl The public URL, where users land after sign-in and sign-out appsettings.Devtest.json

EntraExternalIdOptions and EntraIdentityOptions mark every property [Required] and are validated at startup. An empty value stops the API before it serves a request, rather than failing at the first sign-in. Configuration lists every other key.

The same switch, Authentication:Provider, is read in three places. The rest of the app sees the same normalized claims from either provider.

Concern Keycloak (local) Entra External ID (Azure)
Selected when Authentication:Provider is Keycloak, the default EntraExternalId
Sign-in config Authentication:Keycloak: Authority, FrontendClientId, FrontendClientSecret, ApiAudience Authentication:EntraExternalId: Authority, ClientId, ClientSecret, ApiAudience
Creating users KeycloakAdmin → Keycloak admin API. New users get the VERIFY_EMAIL and UPDATE_PASSWORD actions. EntraIdentity → a Graph invitation email
Demo login Password grant (ROPC) against the realm client Native auth: initiate → challenge → token
Claims transformer KeycloakClaimsTransformer: sub, name, email EntraExternalIdClaimsTransformer: sub or oid, then email, emails or preferred_username
?provider=google on login Sent as kc_idp_hint, which skips straight to Google Ignored. The user flow shows whatever identity providers you’ve added to it.
Where it runs An Aspire container with the appointme realm imported Your tenant, managed in the portal

The three places are AddAppointMeAuthentication (OIDC and JWT), AddDemoMode (the demo authenticator) and AddIdentityModule (IIdentityProvider). Identity & principal explains what happens after the claims are normalized.

Why does the demo login use native authentication?1 min · optional

The demo button signs a fixed user in without sending the browser to the identity provider: the API authenticates on the server, then sets the cookie itself. Keycloak supports this through the password grant. External ID doesn’t support ROPC at all.

The native authentication API is External ID’s way to sign in with an email and password without a redirect, so EntraExternalIdDemoUserAuthenticator calls its initiate, challenge and token endpoints in turn. It insists on a password challenge, and treats anything else, such as a browser fallback, as a failure.

Sign-in round-trips through your tenant, and sign-up sends an invitation email.

Check Expected
Open the app and choose Sign in A redirect to <tenant>.ciamlogin.com, then back to Frontend:BaseUrl, signed in
Sign up with a new email An invitation email. After redeeming it, the user lands on <BaseUrl>/auth/login?returnUrl=/onboarding.
GET /api/v1/login/demo (with Demo:Enabled) A redirect to Frontend:BaseUrl, signed in as the demo user
GET /api/v1/login/demo (demo off) 404

Most failures are a redirect URI, a missing Graph consent, or native auth left switched off.

Symptom Cause Fix
AADSTS50011 after you click Sign in The redirect URI the API sent isn’t registered Register https://<host>/signin-oidc for every host you serve, including the custom domain
The redirect URI uses the azurewebsites.net host behind the Worker X-Original-Host isn’t arriving Check PUBLIC_HOST and the routes pattern in the Worker, and that the request came through the Worker’s route. Program.cs reads the header through ForwardedHostHeaderName.
The API stops at startup with a validation error for EntraExternalIdOptions or EntraIdentityOptions A required value is empty or missing in appsettings.Devtest.json Fill in every key in the table above
Sign-in fails with AADSTS7000215 (invalid client secret) The secret in Key Vault is wrong or expired, or its Key Vault reference didn’t resolve. App Service then passes the literal @Microsoft.KeyVault(...) string. Check the reference status on the Web App’s Environment variables page, fix the secret, and restart
Sign-up fails, and the logs show Unhandled Graph error in POST /invitations No Graph application permission or no admin consent Add User.Invite.All and User.Read.All, then grant consent
Sign-up returns invalid_email_for_idp Graph rejected the address, often a + alias Use a different email. The API tells the user the same thing.
The demo login returns 502 Demo login failed. Native auth is off, the user flow isn’t email and password, or the demo user doesn’t exist The log line Entra demo login: expected a 'password' challenge points at native auth
API calls with a bearer token return 401 The token’s audience isn’t ApiAudience Request a token for your api://<client-id> scope