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 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 for the provider 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}/.default — options.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. SDK callers opt out with DisableDefaultScopes = true on ScopeBuilder; saif auth generate opts out with --no-default-scopes (see Create a JWT for testing APIs). The downstream API's owner still has to grant it to your app in their authorized_apps. Check for a missing user_impersonation/user-groups grant when diagnosing delegated-call 401s.

Why the Okta scope has no -{env} suffix

Entra distinguishes environments through the audience itself — api://{projectId}-{env}/... — because every environment gets its own app registration. Okta's scope string, {projectId}.{scope}, has no environment segment because there's no environment to distinguish below Prod — see below.

⚠️ Okta Non-Prod Environments Are Not Isolated From Each Other

Okta's okta-oidc-client module (iac-okta-modules/modules/okta-oidc-client) takes a single is_production boolean, not a per-environment value. So each application gets two sets of Okta resources, not four: Test, QA, and UAT share one OIDC client, one authorization server (audience api://{project_id}), one set of redirect URIs, and one set of permission groups; only Prod is dedicated. A Terraform change scoped to "Test" — a redirect URI, an auth-server policy — actually changes what QA and UAT authenticate against too, with none of the blast-radius containment Entra gets from per-environment app registrations.

Provider credentials are unaffected — the shared Okta-External-NP TFC variable set (org saif-x, oktapreview.com) is intentionally shared regardless of how application-level resources are split.

Splitting Test/QA/UAT into per-environment clients (matching Entra) has been scoped as an exploratory effort — replacing is_production with an environment variable, a new major module version, and updated App Configuration/APIM references across every consumer — but it's a breaking change with no committed timeline.


🧩 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.