# Set up a per-user OAuth 2.0 connector

Last validated Jun 30, 2026

> **Note:** Aperture connectors are currently in alpha.

\[Missing snippet: aperture\_mcp\_experimental\_note.mdx]

Some external services, such as Google Workspace, Atlassian, and Salesforce, require each user to authorize individually before Aperture can act on their behalf. Aperture's `oauth2_authorization_code` auth type handles this by running an OAuth 2.0 authorization code flow with Proof Key for Code Exchange (PKCE) (S256). Tokens are stored per-user so that credentials are not shared across users.

## Prerequisites

Before you begin, you must meet the following requirements:

* You have an [Aperture instance][kb-get-started] with at least one provider configured.
* You have [admin access][kb-set-up-admin-access] to the Aperture dashboard.
* You have the `connectors` feature flag enabled in your Aperture configuration. Add `"flags": {"connectors": {"value": true}}` if you have not already.
* You have an OAuth 2.0-capable account at the external provider (Google, Salesforce, or similar) with permission to register an OAuth application.
* You have the Model Context Protocol (MCP) server URL for the remote service you want to connect.

## Step 1: Register an OAuth application at the provider

Create an OAuth 2.0 application at your external provider. During registration, configure the following:

1. Set the redirect URI to:

   ```text
   https://<aperture-hostname>/aperture/auth/<connector-id>/callback
   ```

   Replace `<aperture-hostname>` with your Aperture instance's Tailscale hostname and `<connector-id>` with the connector ID you plan to use in your Aperture configuration (for example, `google` or `salesforce`). Connector IDs must start with a letter and contain only letters and digits (matching `[a-zA-Z][a-zA-Z0-9]*`). Hyphens and underscores are not allowed.

2. Enable PKCE with the S256 challenge method if the provider requires explicit opt-in. Aperture uses PKCE (S256) for authorization code flows.

3. Record the following values from the provider's application settings:
   * Client ID
   * Client secret (if issued, not required for PKCE-only public clients)
   * Authorization URL
   * Token URL
   * Required scopes

The following table lists the authorization and token URLs for common providers, along with any required `auth_params`:

| Provider   | Auth URL                                                 | Token URL                                            | Required `auth_params`                            | Required scopes for refresh tokens | Notes                                                                                                     |
| ---------- | -------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Google     | `https://accounts.google.com/o/oauth2/v2/auth`           | `https://oauth2.googleapis.com/token`                | `{"access_type": "offline", "prompt": "consent"}` | (set via `auth_params`)            | Without `access_type: offline`, Google does not issue a refresh token.                                    |
| Salesforce | `https://login.salesforce.com/services/oauth2/authorize` | `https://login.salesforce.com/services/oauth2/token` | None required                                     | `refresh_token`                    | Refer to the example below. You can also use the org-specific domain (`https://<org>.my.salesforce.com`). |

> **Note:**
>
> This guide covers the `oauth2_authorization_code` flow, where you register an OAuth application yourself. MCP servers that support dynamic client registration, such as Atlassian, use the [`oauth2_dcr`][kb-connectors-reference-dcr] auth type instead: Aperture discovers the authorization server and registers a client at runtime, so you do not configure authorization and token URLs. Refer to the [connectors reference][kb-connectors-reference-dcr] to configure one.

## Step 2: Configure the connector in Aperture

Open the **Settings** page of the [Aperture dashboard][kb-web-dashboard] and add a connector entry using the `oauth2_authorization_code` auth type.

The following example configures a Google Workspace connector:

```json
{
  "flags": {
    "connectors": {"value": true}
  },
  "connectors": {
    "servers": {
      "google": {
        "protocol": "mcp",
        "url": "https://mcp.google.example.com/v1/mcp",
        "auth": {
          "type": "oauth2_authorization_code",
          "client_id": "<your-client-id>",
          "client_secret": "<your-client-secret>",
          "auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
          "token_url": "https://oauth2.googleapis.com/token",
          "scopes": ["https://www.googleapis.com/auth/spreadsheets.readonly"],
          "auth_params": {"access_type": "offline", "prompt": "consent"}
        }
      }
    }
  }
}
```

> **Warning:**
>
> Many providers require a specific scope or `auth_params` value before they issue a refresh token. For Google, omitting `access_type: offline` means the provider does not issue a refresh token. Other providers require a scope instead: for example, Salesforce needs `refresh_token`. Check your provider's documentation. Without the correct setting, the connector appears to work initially, but silently stops working after the first access token expires (typically one hour).

The `client_secret` field is optional. If the provider supports PKCE-only public clients and does not issue a client secret, you can omit this field.

The following example configures a Salesforce connector:

```json
{
  "connectors": {
    "servers": {
      "salesforce": {
        "protocol": "mcp",
        "url": "https://api.salesforce.com/platform/mcp/v1/platform/sobject-all",
        "auth": {
          "type": "oauth2_authorization_code",
          "client_id": "<your-salesforce-client-id>",
          "client_secret": "<your-salesforce-client-secret>",
          "auth_url": "https://login.salesforce.com/services/oauth2/authorize",
          "token_url": "https://login.salesforce.com/services/oauth2/token",
          "scopes": ["mcp_api", "refresh_token"]
        }
      }
    }
  }
}
```

## Step 3: Grant users access to the connector's tools

Aperture is deny-by-default. Without grants, users cannot access any connector tools, even after authorizing. Add grants in the `grants` section of your configuration.

The following example grants all users access to all tools and resources from the `google` connector, plus access to the built-in Aperture tools:

```json
{
  "grants": [
    {
      "src": ["*"],
      "app": {
        "tailscale.com/cap/aperture": [
          {"connectors": ["aperture/**"]},
          {"connectors": ["google/**"]}
        ]
      }
    }
  ]
}
```

Connector grants use the `connectors` field with `"connectorID/category/resource"` FQN glob patterns. For example, `"google/tools/*"` grants access to all tools from the `google` connector, while `"google/**"` grants access to all capabilities (tools, resources, templates).

Refer to [grant access to MCP tools][kb-grant-mcp-tool-access] for detailed instructions on configuring grants.

## Step 4: Walk users through the authorization flow

Each user must complete an individual authorization flow before they can use the connector. Aperture identifies the calling user from their Tailscale identity, so the request must originate from a device signed in to your tailnet as that user. No token or API key is sent in the request. The per-user authorization binds to the Tailscale identity that makes this call. The same user must both start and complete the flow: if a different Tailscale user opens the authorization URL, the callback fails. An admin cannot authorize on another user's behalf.

The flow works as follows:

1. The user sends a POST request to start the authorization:

   ```shell
   curl -X POST https://<aperture-hostname>/api/connectors/google/connect
   ```

   Aperture returns a JSON response containing the provider's authorization URL:

   ```json
   {"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&code_challenge=...&code_challenge_method=S256&..."}
   ```

2. The user opens the `auth_url` in a browser and signs in to the external provider.

3. The provider displays a consent screen showing the requested scopes. The user grants consent.

4. The provider redirects the user to `https://<aperture-hostname>/aperture/auth/google/callback`. Aperture renders an HTML page confirming success or failure. If the user declines consent, the page reports that the authorization was canceled, and they must restart the flow. Other provider errors, such as an admin-restricted app, are shown with the provider's error message.

5. Aperture stores the token for that user. Subsequent tool calls from that user are automatically authenticated.

> **Warning:**
>
> Pending authorization flows expire after 15 minutes. If the user does not complete the consent flow within this window, they must start a new flow by calling `POST /api/connectors/<id>/connect` again.

## Step 5: Verify tools appear

After at least one user has authorized, tools from the connector appear in `tools/list` responses. Connectors using `oauth2_authorization_code` populate their tool catalog lazily: Aperture fetches tools on the first authenticated request after startup rather than at startup. This means tools from a per-user connector do not appear until at least one user has completed the authorization flow.

To verify tools are available, send a `tools/list` request:

```shell
curl https://<aperture-hostname>/v1/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'
```

The response should include tools prefixed with the connector ID (for example, `google_search`, `google_query`). Make this request as a user who has both completed the authorization flow and has a matching connector grant (refer to [Step 3][ar-step-3-grant-access]). A user who has authorized but lacks a grant cannot access the connector's tools.

## Check or reset a user's authorization

To check whether a user is authorized, send a `GET` request to `/api/connectors`. As with the authorization request, Aperture identifies the user from their Tailscale identity, so the request must come from a device signed in as that user. The response reports a per-user `status` for each connector:

| Status          | Meaning                                                                                                                                                                            |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready`         | The user is authorized and can use the connector.                                                                                                                                  |
| `needs_auth`    | The user has access but has not authorized yet. They must complete the authorization flow.                                                                                         |
| `misconfigured` | A token is stored, but the OAuth app did not return a refresh token. Add the required scope or `auth_params` (refer to [Step 2][ar-step-2-configure]) and have the user reconnect. |
| `no_access`     | The user has not been granted access to the connector. Add a grant (refer to [Step 3][ar-step-3-grant-access]).                                                                    |

To clear a user's stored credential so they can re-authorize, send a `POST` request to the connector's `disconnect` endpoint as that user:

```shell
curl -X POST https://<aperture-hostname>/api/connectors/google/disconnect
```

After disconnecting, the user can start a new authorization flow.

## Troubleshooting

Use the following sections to diagnose and resolve common issues with per-user OAuth 2.0 connectors.

### Authorization flow expires

Pending authorization flows expire after 15 minutes. If a user reports that the authorization URL no longer works, have them start a new flow by calling `POST /api/connectors/<id>/connect`.

### Tools stop working after a period of inactivity

If tools from a per-user connector stop working after a period of inactivity, the refresh token has likely expired or been revoked by the provider. Aperture treats tokens as expired 60 seconds early and refreshes them on the next request, but when a refresh fails, Aperture deletes the stored credential. The affected user must re-authorize by calling `POST /api/connectors/<id>/connect` again.

### Missing refresh-token setting causes silent token expiry

If a provider does not issue a refresh token, the connector works initially but stops working after the access token expires (typically one hour). Each provider has its own requirement:

* Google requires `auth_params` with `{"access_type": "offline", "prompt": "consent"}`. The `access_type: offline` value is what enables refresh tokens. `prompt: consent` ensures a refresh token is re-issued on subsequent authorizations.
* Salesforce requires the `refresh_token` scope.

Add the required setting to your configuration and have affected users re-authorize. If a user's connector `status` is `misconfigured`, the stored token has no refresh token. Fix the configuration and have the user disconnect and reconnect.

### Auth configuration rejected with unknown field error

The `auth` block uses strict field validation. If a field name is misspelled (for example, `"secert"` instead of `"secret"`), the configuration fails to load with a JSON decoder error. Double-check field names against the [connectors reference][kb-connectors-reference].

### Callback shows "authorization failed"

If the provider redirects to the callback URL but the user sees an "authorization failed" page:

1. Verify that the redirect URI registered at the provider exactly matches `https://<aperture-hostname>/aperture/auth/<connector-id>/callback`.
2. Confirm the `state` parameter was not modified during the redirect. This can occur if a proxy or browser extension alters query parameters.
3. Confirm that the same Tailscale user who started the flow is the one completing it. If a different user opens the authorization URL, the callback fails.

## Next steps

* Refer to the [connectors feature guide][kb-aperture-connectors] for an overview of all connector types and common scenarios.
* Refer to the [connectors reference][kb-connectors-reference] for the complete field reference and all authentication types.
* For connectors that use a single shared credential instead of per-user authorization, refer to [set up an authenticated MCP connector][kb-set-up-authenticated-mcp-connector].
* Refer to [grant access to MCP tools][kb-grant-mcp-tool-access] for detailed instructions on MCP grants.

[ar-step-2-configure]: #step-2-configure-the-connector-in-aperture

[ar-step-3-grant-access]: #step-3-grant-users-access-to-the-connectors-tools

[kb-aperture-connectors]: /docs/aperture/connectors

[kb-connectors-reference]: /docs/aperture/connectors/reference

[kb-connectors-reference-dcr]: /docs/aperture/connectors/reference#dynamic-client-registration-oauth2_dcr

[kb-get-started]: /docs/aperture/get-started

[kb-grant-mcp-tool-access]: /docs/aperture/how-to/grant-mcp-tool-access

[kb-set-up-admin-access]: /docs/aperture/how-to/set-up-admin-access

[kb-set-up-authenticated-mcp-connector]: /docs/aperture/connectors/set-up-authenticated-mcp-connector

[kb-web-dashboard]: /docs/aperture/reference/dashboard
