---
moved_from:
  - reference/how-authentication-works.md
title: How Authentication Works
description: How Forge acquires, validates, and enforces tokens across Entra ID and Okta — the mechanics behind the permission model.
---

# How Authentication Works

The how-to guides tell you [what to put in which file](../../build/identity/configuration/app-permissions.md) and [how to call a downstream API](../../build/apis/calling-apis.md). 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.

```mermaid
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](../authorization.md#permission-naming-conventions) 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](../authorization.md#platform-managed-delegation-scopes) 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:

```mermaid
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:

!!! note "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.

!!! note "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](../../build/identity/testing/create-jwt-for-testing-apis.md)). 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.

!!! note "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](../authorization.md) - Naming conventions, terminology, claim mapping
- [App Permissions](../../build/identity/configuration/app-permissions.md) - Expose permissions on your API
- [Calling Downstream APIs](../../build/apis/calling-apis.md) - Configure Kiota clients and token providers
- [Business Roles](../../build/identity/configuration/business-roles.md) - How users get `App.*` roles
- [ADR 0006: Okta Multi-Tenant Architecture](../decisions/0006-okta-multi-tenant-architecture.md) - Internal/external organization boundaries
