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-fallbackandauthentication.kiota.client_credentials_reason, and the implementation also logs a structuredReasonvalue 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.
📚 Related Documentation¶
- Authorization Reference - Naming conventions, terminology, claim mapping
- App Permissions - Expose permissions on your API
- Calling Downstream APIs - Configure Kiota clients and token providers
- Business Roles - How users get
App.*roles - ADR 0006: Okta Multi-Tenant Architecture - Internal/external organization boundaries