# Configuring Okta

The MCP Gateway can use Okta as the identity provider behind its downstream
OAuth flow. The `mcp-okta-oauth-inbound` policy is an Okta-friendly wrapper
around the generic `mcp-oauth-inbound` policy: provide your Okta domain, a
client ID, and a client secret, and the policy derives the OIDC issuer, JWKS
URL, and authorize and token URLs for you.

This guide walks through the Okta admin console setup, then wires the policy
into a gateway project. Read the [authentication overview](./overview.mdx) first
for the two-layer model and the role each policy plays.

The wrapper supports both Okta's **org authorization server** (the default) and
**custom authorization servers**. Custom authorization servers give you control
over scopes and audiences; the org server is fine for the gateway's
identity-only use of Okta.

## Set up Okta

The MCP Gateway acts as an OAuth 2.1 authorization server in front of Okta. Okta
handles browser login and identity; the gateway issues its own access tokens
that bind to MCP routes. The Okta application you create represents the
**gateway's identity** against Okta, not the MCP client.

### Create an OIDC application

1. In the Okta Admin Console, open **Applications → Applications** and click
   **Create App Integration**.
2. Choose **OIDC - OpenID Connect** as the sign-in method and **Web
   Application** as the application type. Click **Next**.
3. Give the integration a name (for example, `Zuplo MCP Gateway`).
4. Under **Grant types**, leave **Authorization Code** checked. The gateway does
   not need refresh tokens from Okta — it uses Okta only for browser identity,
   not as a long-running token source.
5. Set **Sign-in redirect URIs** to your gateway's
   `https://<gateway-host>/oauth/callback`. Add
   `http://localhost:9000/oauth/callback` for local development with
   `zuplo dev`.
6. Under **Assignments**, restrict access to the groups or users who should be
   able to authenticate against the gateway.
7. Click **Save**.

Note the **Client ID** and **Client Secret** from the application's **General**
tab. You'll wire these into the policy in the next section.

### Optional: pick a custom authorization server

By default the policy uses the Okta org authorization server, which is enough
for gateway browser identity. If you already operate a custom authorization
server, open **Security → API → Authorization Servers** in the Okta admin
console and note the server's **name** (such as `default` or `customer-portal`).
You'll pass that name as the `authorizationServerId` option.

## Wire the policy into the gateway

Add the policy to `config/policies.json`:

```json
{
  "name": "okta-managed-oauth",
  "policyType": "mcp-okta-oauth-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpOktaOAuthInboundPolicy",
    "options": {
      "oktaDomain": "$env(OKTA_DOMAIN)",
      "clientId": "$env(OKTA_CLIENT_ID)",
      "clientSecret": "$env(OKTA_CLIENT_SECRET)"
    }
  }
}
```

:::caution

`oktaDomain` is a **bare hostname** like `acme.okta.com` or
`acme.oktapreview.com`. Don't include `https://`, a trailing slash, or an
`/oauth2/...` path.

:::

Set the three environment variables in your project's environment configuration.
`OKTA_DOMAIN` goes in plain config; the secret values belong in the project
secret store.

To use a custom authorization server, add `authorizationServerId`:

```json
{
  "options": {
    "oktaDomain": "$env(OKTA_DOMAIN)",
    "authorizationServerId": "default",
    "clientId": "$env(OKTA_CLIENT_ID)",
    "clientSecret": "$env(OKTA_CLIENT_SECRET)"
  }
}
```

Attach the policy to each MCP route in `config/routes.oas.json`:

```jsonc
{
  "paths": {
    "/mcp/linear-v1": {
      "get,post": {
        "operationId": "linear-mcp-server",
        "x-zuplo-route": {
          "corsPolicy": "none",
          "handler": {
            "module": "$import(@zuplo/runtime/mcp-gateway)",
            "export": "McpProxyHandler",
            "options": {
              "rewritePattern": "https://mcp.linear.app/mcp",
            },
          },
          "policies": {
            "inbound": ["okta-managed-oauth", "mcp-token-exchange-linear"],
          },
        },
      },
    },
  },
}
```

Register the gateway plugin in `modules/zuplo.runtime.ts`:

```ts
import { RuntimeExtensions } from "@zuplo/runtime";
import { McpGatewayPlugin } from "@zuplo/runtime/mcp-gateway";

export function runtimeInit(runtime: RuntimeExtensions) {
  runtime.addPlugin(new McpGatewayPlugin());
}
```

One MCP OAuth policy serves every MCP route in the project — attach the same
policy by name to each route.

## Full options reference

| Option                                    | Required | Default                | Notes                                                                                 |
| ----------------------------------------- | -------- | ---------------------- | ------------------------------------------------------------------------------------- |
| `oktaDomain`                              | yes      | —                      | Bare hostname (`acme.okta.com`). No scheme.                                           |
| `authorizationServerId`                   | no       | unset (org server)     | Name of an Okta custom authorization server, e.g. `default`.                          |
| `clientId`                                | yes      | —                      | Okta application client ID.                                                           |
| `clientSecret`                            | yes      | —                      | Okta application client secret. Use `$env(...)`.                                      |
| `scope`                                   | no       | `openid profile email` | OIDC scopes requested during browser login.                                           |
| `gateway.accessTokenTtlSeconds`           | no       | `900`                  | Gateway-issued access token lifetime.                                                 |
| `gateway.refreshTokenTtlSeconds`          | no       | long-lived             | Gateway-issued refresh token lifetime. Override only if you need to shorten sessions. |
| `gateway.cimdEnabled`                     | no       | `true`                 | Advertise CIMD support in AS metadata.                                                |
| `browserLoginOverrides.sessionTtlSeconds` | no       | `28800`                | Browser session cookie lifetime (8 hours).                                            |
| `browserLoginOverrides.stateTtlSeconds`   | no       | `900`                  | Browser-login state record lifetime.                                                  |
| `browserLoginOverrides.remoteTimeoutMs`   | no       | `10000`                | Outbound timeout to Okta (token exchange, JWKS fetch).                                |

## Test the configuration

The fastest sanity check is to connect an MCP client:

1. Open Claude Desktop, Cursor, Claude Code, or another OAuth-aware MCP client.
2. Add a remote MCP server pointing at one of your `/mcp/{slug}` routes on the
   gateway.
3. The client should redirect you to Okta's login page. After login, the
   gateway's consent screen renders. Approve it.
4. The client receives an access token and can call `tools/list`.

If something fails partway through, walk the flow manually using the
[manual OAuth testing guide](./manual-oauth-testing.mdx) — it exercises every
endpoint with `curl` so you can see the raw responses.

## Common issues

- **"Invalid Okta domain" at boot.** `oktaDomain` includes a scheme prefix,
  trailing slash, or path. Use `acme.okta.com`.
- **Browser login redirects but the callback fails.** The
  `https://<gateway-host>/oauth/callback` URL isn't on the application's
  **Sign-in redirect URIs** allow-list in Okta.
- **`invalid_audience` from the gateway's token endpoint.** The MCP client is
  reusing a token bound to a different route. Each gateway-issued token binds to
  one MCP route.
- **MCP client can't discover the AS.** Confirm the `mcp-okta-oauth-inbound`
  policy is attached to the route in `routes.oas.json` and the
  `McpGatewayPlugin` is registered in `modules/zuplo.runtime.ts`. The internal
  OAuth endpoints register only when both are present.

## Related

- [Authentication overview](./overview.mdx)
- [Configuring Auth0](./configuring-auth0.mdx) — the most-used wrapper
- [Configuring a generic OIDC provider](./configuring-generic-oidc.mdx) — for
  IdPs without a first-class wrapper.
- [Per-user OAuth to upstream MCP servers](./upstream-oauth.mdx)
