> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formal.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth and Linked Accounts

> Send each user's own upstream account and sign clients in with Formal OAuth on MCP, HTTP, gRPC, and LLM resources

## Overview

OAuth on a [Resource](https://docs.formal.ai/docs/glossary/index.md#resource) has two independent halves:

| Half | Between | What it does |
| - | - | - |
| **Linked accounts** (upstream) | [Connector](https://docs.formal.ai/docs/glossary/index.md#connector) and the upstream service | Each user links their own account once. The Connector sends that user's access token upstream. |
| **Formal OAuth** (downstream) | Client and Connector | Clients sign in with Formal and get a short-lived token for one Resource. |

Linked accounts replace shared credentials. Requests reach the upstream as the person who made them, with that person's upstream permissions. Users never see or handle the upstream token.

You can enable OAuth on MCP, HTTP, gRPC, and LLM Resources.

For remote MCP servers, the [MCP Gateway](https://docs.formal.ai/docs/guides/core-concepts/connectors/mcp-gateway.md) guide covers the full setup, including listeners, hostnames, and client configuration.

Linked accounts are sent through a [native user](https://docs.formal.ai/docs/guides/core-concepts/resources/native-users.md). A Linked Account native user sends the requesting user's linked token instead of a stored credential, and the Resource's native user selection picks it like any other native user. Linked Account native users need `native_users_v3_enabled` on the Resource and Connector 2.25.14 or later.

## How the Connector picks the linked account

The Connector sends the linked account of the human the request is for:

| Request authenticated with | Linked account used |
| - | - |
| A Formal OAuth access token | The user who approved the client |
| [Identity headers](https://docs.formal.ai/docs/guides/core-concepts/connectors/mcp-gateway.md#identity-headers) from the Formal Endpoint | The signed-in user |
| An [OIDC federation token](https://docs.formal.ai/docs/guides/integrations/oidc.md#federated-connector-access) that names an end user | The end user (Connector 2.25.2 or later) |
| An OIDC federation token without an end user | None. The machine user can't link accounts, so the request is rejected. |

An end user asserted in a request header, such as `X-Formal-End-User-Identity`, never selects the linked account. It only sets the [end-user identity](https://docs.formal.ai/docs/glossary/index.md#end-user-identity) for policies and logs.

> [!WARNING]
>
> A federation token's end user comes from the integration's **End-User Email Expression**. Workloads that pass that check act upstream as the resolved person. Only map claims the workload can't choose itself, such as a verified `sub` or email claim. Avoid instance tags that anyone with tagging access can set.

If the user hasn't linked an account, or the link expired, the Connector rejects the request. The error tells the user to link the account on the Resource's page in the [Catalog](https://catalog.formal.ai).

## Set up linked accounts (upstream)

### 1. Choose the OAuth app

Formal needs an OAuth client to sign users in to the upstream's authorization server.

> [accordion] Formal's app (formal)
>
> You don't register anything. Formal identifies itself in one of two ways:
>
> * With an app Formal registered with the provider, when there is one.
> * Otherwise, with a [Client ID Metadata Document](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents) (CIMD), when the authorization server supports it.
>
> Formal checks the authorization server when you save. If neither works, saving fails. Use your own OAuth app instead.

> [accordion] Your OAuth app (pre\_registered)
>
> Use this for providers such as GitHub, where you register an app yourself.
>
> 1. Register an OAuth app with the provider.
> 2. Add the **Redirect URI** that Formal shows to the app's allowed redirect URIs.
> 3. Enter the app's **Client ID** and, if it has one, its **Client secret**.
>
> Formal encrypts the secret and never shows it again. Leave the secret empty for a public client, which uses PKCE alone.

> [!NOTE]
>
> Formal doesn't register itself with authorization servers through Dynamic Client Registration (DCR).

### 2. Point Formal at the authorization server

Formal discovers the authorization server from the Resource's [protected resource metadata](https://datatracker.ietf.org/doc/html/rfc9728) (RFC 9728). MCP servers usually publish it.

Most other APIs don't. For those, set the **Authorization server** to the provider's issuer URL. Formal then reads the issuer's authorization server metadata directly.

| Upstream | Authorization server |
| - | - |
| GitHub REST API (`api.github.com`) | `https://github.com/login/oauth` |
| An MCP server with protected resource metadata | Leave empty |

The issuer must use `https`, have no query or fragment, and exactly match the `issuer` its metadata reports.

**Verify:**

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
curl -s https://github.com/login/oauth/.well-known/oauth-authorization-server \
  | jq '{issuer, authorization_endpoint, token_endpoint}'
# Expected: "issuer" is exactly https://github.com/login/oauth
```

### 3. Choose scopes

Use **Permissions to request** to list scopes. When you leave it empty:

* For an MCP server, Formal requests the scopes in its sign-in challenge, or else those its protected resource metadata lists.
* Otherwise, Formal omits the scope parameter, and the provider's defaults apply.

You can change the scopes later. New scopes apply the next time each user links. Linked accounts keep the scopes they were granted.

### 4. Save the settings

> [tab] Control Plane
>
> 1. Go to [Resources](https://app.formal.ai/resources) and open the Resource.
> 2. Open the **Authentication** tab.
> 3. Under **Linked Accounts**, click **Set Up Linked Accounts**.
> 4. Choose the **OAuth app**, and set the **Authorization server** and **Permissions to request** if needed.
> 5. Click **Save linked accounts**.
>
> Formal adds a `linked-account` native user that sends each user's token as a bearer token. It becomes the default native user unless the Resource already has one.
>
> You can also click **Add User** under **Native Users** and choose **Linked Account (OAuth)**.
>
> For MCP servers, configure **Authentication** on the server's **Configuration** tab in the [Catalog](https://app.formal.ai/catalog) instead.

> [tab] Terraform
>
> ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> resource "formal_resource" "github" {
>   name       = "github"
>   technology = "http"
>   hostname   = "api.github.com"
>   port       = 443
>
>   native_users_v3_enabled = true
> }
>
> resource "formal_resource_oauth_settings" "github" {
>   resource_id              = formal_resource.github.id
>   upstream_oauth_mode      = "pre_registered"
>   client_id                = var.github_oauth_app_client_id
>   client_secret_wo         = var.github_oauth_app_client_secret
>   client_secret_wo_version = 1
>   issuer                   = "https://github.com/login/oauth"
>   requested_scopes         = ["repo", "read:org"]
> }
>
> resource "formal_native_user_v3" "linked_account" {
>   resource_id = formal_resource_oauth_settings.github.resource_id
>   label       = "linked-account"
>
>   linked_http_bearer {
>     header = "Authorization"
>   }
> }
>
> resource "formal_resource_native_user_selection" "github" {
>   resource_id = formal_resource.github.id
>   cel         = "\"${formal_native_user_v3.linked_account.id}\""
> }
> ```
>
> Omit `upstream_oauth_mode` to use Formal's app. See [`formal_resource_oauth_settings`](https://registry.terraform.io/providers/formalco/formal/latest/docs/resources/resource_oauth_settings).
>
> Terraform doesn't add the native user or the selection for you. Without `formal_resource_native_user_selection`, the Connector refuses requests that don't name a native user with `@<label>`. Formal OAuth clients and the Formal Endpoint never name one.

You can update the scopes and the client secret in place. Linked accounts and client approvals are kept.

> [!WARNING]
>
> The OAuth app mode, client ID, and authorization server are fixed. Changing one deletes and recreates the settings. That unlinks every account and revokes every client approval for the Resource.

**Verify:** Open the Resource in the [Catalog](https://catalog.formal.ai) as a user, and go to the **Installation** tab. You'll see a **Your account** panel with a **Link account** button.

### 5. Choose how the token is sent

| Credential type | Sends |
| - | - |
| `linked_http_bearer` | The token as a bearer token in a named header, such as `Authorization: Bearer <token>` |
| `linked_http_basic` | Basic credentials in a named header, with your `username` and the token as the password |

Use `linked_http_basic` for upstreams that only accept Basic credentials, such as git over HTTPS:

```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
resource "formal_native_user_v3" "linked_account" {
  resource_id = formal_resource_oauth_settings.github.resource_id
  label       = "linked-account"

  linked_http_basic {
    header   = "Authorization"
    username = "<USERNAME>"
  }
}
```

The [default selection](https://docs.formal.ai/docs/guides/core-concepts/resources/native-users.md#select-a-default-native-user) can mix linked accounts with shared credentials:

```cel theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
"contractors" in user.groups
  ? native_users["read-only"]
  : native_users["linked-account"]
```

Clients can also request any of the Resource's native users by label with `@<label>`. That includes one with a shared credential in place of their linked account. To restrict that, write a [policy](https://docs.formal.ai/docs/guides/core-concepts/resources/native-users.md#control-explicit-overrides-with-policies).

## Link an account

Users link their own accounts:

1. Open the Resource in the [Catalog](https://catalog.formal.ai), and go to the **Installation** tab.
2. Under **Your account**, click **Link account**, and sign in to the provider.

Users can also link during [Formal OAuth](#formal-oauth-downstream) sign-in, on the consent screen.

Formal stores tokens encrypted in its control plane. When the provider issues refresh tokens, Formal refreshes access tokens before they expire. Otherwise, users link again once the access token expires.

**Verify:** Send a request through the Connector as that user. For example, through the Formal Endpoint:

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
curl -s https://api.github.com/user | jq .login
# Expected: the GitHub login of the account you linked
```

## Formal OAuth (downstream)

With OAuth enabled, the Connector is an OAuth protected resource for the Resource. Clients that support MCP-style authorization sign in with Formal:

1. The client calls the Resource's Connector address without a token.
2. The Connector answers `401 Unauthorized`. Its `WWW-Authenticate` header points to `/.well-known/oauth-protected-resource`, which lists Formal as the authorization server.
3. The user signs in to Formal and approves the client on a Catalog consent screen. If the Resource uses linked accounts, the user links there too.
4. Formal issues an access token bound to this Resource's address, plus a refresh token.

The Connector address uses [smart routing](https://docs.formal.ai/docs/guides/core-concepts/resources/smart-routing.md):

```text theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
https://<resource-name>.<connector-hostname>
```

The Connector validates access tokens offline and removes them before forwarding the request.

| Token | Lifetime |
| - | - |
| Access token | 15 minutes, for one Resource |
| Refresh token | 90 days, rotated on each use |

> [!NOTE]
>
> Formal OAuth works when upstream authentication is linked accounts or shared credentials. Requests that identify no Formal user are rejected.

### Clients that can sign in

Every client must use PKCE with `S256`. Formal's authorization server doesn't support Dynamic Client Registration (DCR). Clients identify themselves in one of two ways:

| Client type | How it identifies itself | Examples |
| - | - | - |
| **CIMD** | Its own Client ID Metadata Document URL | Claude, ChatGPT, Claude Code |
| **Pre-registered** | A client ID you create in Formal | Clients without CIMD support, such as Cursor or Devin |

To pre-register a client:

> [tab] Control Plane
>
> 1. Go to [OAuth](https://app.formal.ai/oauth) and open the **Clients** tab.
> 2. Create a client with its name and exact redirect URIs.
> 3. Make it confidential if the client can keep a secret. Copy the secret now; Formal shows it only once.
> 4. Configure the client with the client ID and, if confidential, the secret.

> [tab] Terraform
>
> ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> resource "formal_oauth_client" "cloud_agent" {
>   name          = "Cloud agent"
>   redirect_uris = ["https://agent.example.com/oauth/callback"]
>   confidential  = true
> }
> ```
>
> See [`formal_oauth_client`](https://registry.terraform.io/providers/formalco/formal/latest/docs/resources/oauth_client).

Redirect URIs must be `https`, `http` on a loopback host (any port), or a private-use scheme containing a period. Confidential clients authenticate with `client_secret_basic` or `client_secret_post`. Public clients use PKCE alone.

## Manage approvals and linked accounts

Users manage their own:

* **Authorized apps** in the Catalog account menu lists every client they approved, with a revoke action.
* Each Resource's **Installation** tab in the Catalog shows their linked account, with **Unlink**.

Administrators manage the whole organization on the [OAuth](https://app.formal.ai/oauth) page:

| Tab | Shows | Action |
| - | - | - |
| **Authorized apps** | Every user's approved clients, and what each can access | **Revoke** |
| **Linked accounts** | Every user's linked accounts, with scopes and token expiry | **Unlink** |
| **Clients** | Pre-registered clients | Create, edit, rotate secret, delete |

Revoking an approval stops the client from refreshing. Access tokens it already holds keep working on Connector Resources until they expire, within 15 minutes.

Unlinking revokes the tokens at the provider when it supports revocation, then deletes them. If the provider doesn't confirm, revoke the authorization in the provider's settings too.

## Policies and logs

Requests authenticated with a Formal OAuth access token carry `input.user.oauth`: the client's ID, name, type, and the consent ID. Logs record the same fields under `user.formal.oauth`.

A `cimd` client chooses its own name, so match on `input.user.oauth.client.id` or `type`. See [OAuth clients](https://docs.formal.ai/docs/guides/policies/evaluation.md#oauth-clients) for every field and an example that allows only pre-registered clients.

## Troubleshooting

> [accordion] "Formal has no current \<resource> sign-in for you"
>
> **Cause:** The user the request is for has no linked account, or its access token expired without a refresh token.
>
> **Fix:** Link or re-link the account on the Resource's page in the Catalog. For OIDC federation tokens, check these too:
>
> * The federation token names an end user. The mint response includes `end_user_id`.
> * That end user, not the machine user, linked the account.
> * The Connector runs 2.25.2 or later.

> [accordion] "No default native user is configured for \<resource>"
>
> **Cause:** The Resource has linked accounts but no default native user, and the request didn't name one with `@<label>`.
>
> **Fix:** Make the `linked-account` native user the default. With Terraform, add a `formal_resource_native_user_selection` that picks it.

> [accordion] Requests reach the upstream without the user's token
>
> **Cause:** The default native user, or the one the client requested with `@<label>`, sends a shared credential.
>
> **Fix:** Make the `linked-account` native user the default. To stop clients requesting another one, write a [policy](https://docs.formal.ai/docs/guides/core-concepts/resources/native-users.md#control-explicit-overrides-with-policies).

> [accordion] "\<resource> rejected your credentials"
>
> **Cause:** The upstream refused the linked account's token, for example after a password change or revoked grant. The native user may also send it in a form the upstream doesn't accept.
>
> **Fix:** Re-link the account. If the token lacks a permission, add the scope under **Permissions to request**, then re-link. For git over HTTPS, use `linked_http_basic` and Connector 2.25.14 or later. Older Connectors always send the token as a bearer token.

> [accordion] Saving linked accounts fails
>
> **Possible causes:**
>
> * The Resource publishes no protected resource metadata, and no authorization server is set
> * The authorization server supports neither CIMD nor an app Formal registered
>
> **Fix:** Set the **Authorization server** to the provider's issuer. If saving still fails, switch to **Your OAuth App**.

> [accordion] Invalid client error during Formal OAuth sign-in
>
> **Cause:** The client doesn't support Client ID Metadata Documents, or its redirect URI isn't registered.
>
> **Fix:** [Pre-register the client](#clients-that-can-sign-in) with its exact redirect URIs.

## Next Steps

> [card] [MCP Gateway](https://docs.formal.ai/docs/guides/core-concepts/connectors/mcp-gateway.md)
>
> Route remote MCP servers through a Connector

> [card] [OIDC](https://docs.formal.ai/docs/guides/integrations/oidc.md)
>
> Give workloads federated Connector access

> [card] [Native Users](https://docs.formal.ai/docs/guides/core-concepts/resources/native-users.md)
>
> Send shared credentials instead

> [card] [Policy Evaluation](https://docs.formal.ai/docs/guides/policies/evaluation.md#oauth-clients)
>
> Write policies on OAuth clients


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.