Skip to content

How Authentication Works

The how-to guides tell you what to put in which file and how to call a downstream API. This page explains what the platform actually does with that configuration — useful when you're debugging a 401, reading platform logs, or writing raw APIM policies.


🏛️ The Big Picture

Forge runs two identity providers side by side:

  • Corporate (Entra ID) — internal employees and service accounts
  • External (Okta) — policyholders, injured workers, employers, and providers

Both are always active. Every API registers in both, and APIM accepts valid tokens from either. Which provider a token comes from is determined by the caller, not by your configuration.

flowchart LR
    C[Caller] -->|"JWT (Entra or Okta)"| APIM[Azure API Management]
    APIM -->|validates scp / roles / user-groups| API[Your API]
    API -->|"Kiota client + token provider"| D[Downstream API]

There are two halves to the story: validating tokens coming in and acquiring tokens going out.


🛡️ Inbound: How Tokens Are Validated

Authorization is enforced by Azure API Management, not by your application code. When your API deploys, the platform generates APIM policies from the @useAuth declaration in your TypeSpec.

@useAuth(Scopes<["Client.Read"]> | Roles<["App.Read"]>) generates these claim checks:

Check Token claim Who satisfies it
Scopes<["Client.Read"]> scp contains Client.Read Delegated callers (both providers), Okta service-to-service
Roles<["App.Read"]> (Entra) roles contains App.Read Users with a Business Role, Entra service-to-service callers
Roles<["App.Read"]> (Okta) user-groups contains App.Read External users with a Business Role

This is why the naming convention maps Client.* to scp and App.* to roles/user-groups — the TypeSpec constructs check specific claims, and the prefix tells you which claim a permission lands in.

For user-delegated calls, APIM requires true authorization: the calling application must hold the platform delegation scope (user_impersonation for Entra, user-groups for Okta) and the user must hold an authorized role. If either is missing, the request is rejected — a token from an authorized app carrying an unauthorized user fails, and vice versa.


🔑 Outbound: How Tokens Are Acquired

When your app calls a downstream API through a configured Kiota client, a token provider picks the OAuth flow. The default is DefaultAccessTokenProvider, which decides per request based on the incoming token:

flowchart TD
    A[Incoming request token context] --> B{Provider?}
    B -->|External / Okta| TE[Token exchange<br/>user context preserved]
    B -->|Corp / Entra| C{Incoming token present and type?}
    C -->|"Yes: user token<br/>(idtyp absent or 'user')"| OBO[On-behalf-of flow<br/>user context preserved]
    C -->|"Yes: app token<br/>(idtyp = 'app')"| CC[Client credentials fallback<br/>app identity only]
    C -->|"No incoming token<br/>(background/daemon)"| CC
  • OBO (Entra) — exchanges the incoming user token for a downstream token carrying the same user.
  • Token exchange (Okta) — the external equivalent; always used on the Okta path.
  • Client credentials fallback (Entra) — when the incoming token is an app token, or there's no incoming token at all (e.g. a background/daemon Function App with no inbound bearer token), there is no user assertion to exchange, so the provider falls back to the app's own identity. You can see this in trace/span attributes as authentication.kiota.flow_type=client-credentials-fallback and authentication.kiota.client_credentials_reason, and the implementation also logs a structured Reason value for the same branch.

If you need stricter behavior, TokenExchangeAccessTokenProvider always preserves user context (app tokens become errors instead of falling back), and ClientCredentialsTokenProvider always calls as the service.

How options.Scopes Becomes a Token Request

The ScopeBuilder translates the Client.* names you configure into provider-specific scope strings:

Flow Corp (Entra) External (Okta)
Delegated (OBO / token exchange) api://{projectId}-{env}/{scope} per scope, plus user_impersonation auto-appended {projectId}.{scope} per scope, plus user-groups auto-appended
Client credentials Always api://{projectId}-{env}/.defaultoptions.Scopes is not sent {projectId}.{scope} per scope — options.Scopes is required

Two consequences worth understanding:

Why .default doesn't make options.Scopes pointless

Entra client-credentials tokens can only carry the app roles already granted to the caller, so Entra ignores requested scope names — .default means "everything I've been granted". Okta has no equivalent; callers must name the scopes they want. The same options.Scopes = ["Client.Read"] configuration serves both: ignored by Entra, required by Okta.

Why you never see user_impersonation in your config as a caller

The platform appends the delegation scope automatically for delegated flows (disable with DisableDefaultScopes = true). The downstream API's owner still has to grant it to your app in their authorized_apps — which is why a missing user_impersonation/user-groups grant is the most common cause of delegated-call 401s.


🧩 How the Pieces Fit

Putting both halves together for each access pattern:

Access pattern Caller requests Token arrives with APIM checks
User-delegated (Entra) api://…/Client.Read + user_impersonation scp: Client.Read, roles: user's App.* Scopes<> or Roles<>
User-delegated (Okta) {projectId}.Client.Read + {projectId}.user-groups scp: {projectId}.Client.Read, user-groups: user's {projectId}.App.* Scopes<> or Roles<>
Service-to-service (Entra) api://…/.default roles: granted App.* Roles<>
Service-to-service (Okta) {projectId}.Client.Read scp: {projectId}.Client.Read Scopes<>

One @useAuth(Scopes<["Client.Read"]> | Roles<["App.Read"]>) declaration covers all four rows — which is the reason the permission model needs only two prefixes.