---
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](../guides/security/configuration/app-permissions.md) and [how to call a downstream API](../guides/development/calling-apis.md). 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.

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

```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 (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.

---

## 📚 Related Documentation

- [Authorization Reference](authorization.md) — naming conventions, terminology, claim mapping
- [App Permissions](../guides/security/configuration/app-permissions.md) — expose permissions on your API
- [Calling Downstream APIs](../guides/development/calling-apis.md) — configure Kiota clients and token providers
- [Business Roles](../guides/security/configuration/business-roles.md) — how users get `App.*` roles
