---
title: "Connect with OAuth"
description: "Authorize MCP clients and the ThunderPhone CLI without sharing an API key."
---

OAuth lets an app connect to one ThunderPhone organization with the permissions you approve. Directory clients should use OAuth by default. Organization API keys remain available for clients that require a manually configured Bearer token.

## Approve a connection

Start the connection in your MCP client. Sign in to ThunderPhone, check the app name and requested permissions, choose an organization, and select **Approve**. Select **Deny** if you did not start the connection or do not trust the app. App names are supplied by their developers and are not a verification badge.

Read permissions expose the named data, including recordings and transcripts when `calls:read` is requested. Write permissions can change or delete resources; calls, campaigns, phone purchases, and deployments can incur costs or affect production. Your organization role still applies.

For a CLI connection, open the verification link shown in your terminal, compare the eight-character code, select your organization, and approve. Merely opening the link does not grant access. Codes expire after 15 minutes.

## Disconnect an app

Open **Organization → API keys → Authorized apps** in the dashboard and choose **Revoke**. This revokes your selected authorization for the current organization, including its access and refresh tokens. Reconnect from the app if you want to authorize it again.

## Discovery for MCP clients

Use the server URL `https://api.thunderphone.com/v1/mcp`. A request without valid authentication receives `401` with:

```http
WWW-Authenticate: Bearer resource_metadata="https://api.thunderphone.com/.well-known/oauth-protected-resource"
```

Fetch that document, then fetch the authorization server's metadata at `https://api.thunderphone.com/.well-known/oauth-authorization-server`. Resource metadata is also available at `/.well-known/oauth-protected-resource/v1/mcp`. Use the returned endpoints instead of constructing them. The resource identifier is `https://api.thunderphone.com/v1/mcp`.

The server supports authorization code with **S256 PKCE**, rotating refresh tokens, public dynamic client registration, revocation, and the device authorization grant. There are no client secrets or implicit grants. OpenID discovery is also available at `/.well-known/openid-configuration`; it includes the same authorization-server fields plus `subject_types_supported: ["public"]` and the userinfo endpoint. ID tokens and Client ID Metadata Documents (CIMD) are not supported.

### Register a public client

Send JSON to `POST /v1/oauth/register`:

```json
{
  "client_name": "My MCP client",
  "redirect_uris": ["http://127.0.0.1:8765/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"]
}
```

Save the returned `client_id`. Redirects must use HTTPS, or HTTP on `127.0.0.1` or `localhost`. Register the exact callback URI, including its port and path. Fragments and embedded credentials are rejected. Optional `client_uri` and `logo_uri` must use HTTPS; ThunderPhone does not fetch them during registration. Registration is rate-limited. Registered clients do not expire and remain registered when a connection is revoked. HTTPS callbacks include `https://chatgpt.com/connector/oauth/<id>` and `https://chatgpt.com/connector_platform_oauth_redirect`. Each connection may register its own client.

### Authorization code

Open the discovered authorization endpoint with `client_id`, the exact registered `redirect_uri`, `response_type=code`, a random `state`, `scope`, `code_challenge`, `code_challenge_method=S256`, and `resource=https://api.thunderphone.com/v1/mcp`. Compute the challenge as the unpadded base64url SHA-256 digest of a fresh high-entropy PKCE verifier. Check the returned `state` and `iss` before exchanging the code. Every authorization response, including denial and protocol errors, identifies the issuer with `iss`, exactly matching the discovered `issuer`. Errors for an invalid client or callback are returned locally without redirecting to that callback.

Exchange at `POST /v1/oauth/token` using form encoding (JSON is also accepted):

```text
grant_type=authorization_code
client_id=<your client id>
code=<single-use authorization code>
redirect_uri=<exact registered redirect URI>
code_verifier=<original PKCE verifier>
resource=https://api.thunderphone.com/v1/mcp
```

The optional `resource` parameter is accepted on both authorization and token requests (including refresh and device exchanges). When omitted, it defaults to the discovered MCP resource. When present, it must exactly match that resource; other values return `invalid_target`. Access tokens carry that audience, and MCP rejects tokens with a missing or different audience with `401` and a discovery challenge.

Authorization requests and codes expire after 10 minutes. Every token response contains `access_token`, `token_type` (`Bearer`), `expires_in` (3600 seconds by default), `refresh_token`, `scope`, `organization_id`, and `organization_name`. Send access tokens only through the `Authorization: Bearer` header. Never put tokens in URLs, logs, source control, or chat.

### Refresh and revoke

Refresh with `grant_type=refresh_token`, `client_id`, `refresh_token`, and `resource` at the token endpoint. Save the new refresh token atomically and stop using the old one. Refresh tokens expire after 30 days without a successful refresh. An optional `scope` may narrow the granted permissions. `offline_access` is always included and refresh tokens are always issued. An initial request without `scope` grants only `offline_access`, so clients should request the permissions they need.

Code reuse and rotated refresh-token reuse revoke the entire authorization. Serialize refreshes within a client; replaying a successfully exchanged credential is not a safe retry strategy.

To disconnect, send `token` and `client_id` to `POST /v1/oauth/revoke`. Revoking either token revokes its authorization, including all tokens issued from it. An unknown token returns success without disclosing whether it exists. Dashboard sessions can list their own authorizations at `GET /v1/oauth/grants` and revoke one at `DELETE /v1/oauth/grants/<id>`, with `X-ThunderPhone-Org` selecting the organization.

### Device authorization

Device authorization is restricted to pre-registered clients; dynamically registered clients receive `unauthorized_client`. The pre-registered public client `thunderphone-cli` supports device authorization and refresh. Send `client_id=thunderphone-cli` and `scope` to `POST /v1/oauth/device/code`. Display `user_code` and `verification_uri` to the user, or open `verification_uri_complete`.

Poll the token endpoint with `grant_type=urn:ietf:params:oauth:grant-type:device_code`, `client_id`, and `device_code`, waiting at least the returned `interval` (5 seconds). Continue on `authorization_pending`. On `slow_down`, use the new `interval` returned in the response (increased by 5 seconds) for every subsequent request. Stop on `access_denied`, `expired_token`, or any other error. Never approve a device code automatically.

The pre-registered `thunderphone-mcp` client accepts `http://127.0.0.1/callback` and `http://localhost/callback` on any port. Scheme, host, path and query must match; the token exchange must use the exact redirect URI (including port) from authorization. Dynamically registered clients require exact redirect-URI matching, including the port. Register a different callback dynamically if you need another path.

### Account identity and workspace domain checks

Request both `openid email` alongside the operation scopes your client needs. Call the discovered `userinfo_endpoint` (`GET /v1/oauth/userinfo`) with the access token in the Bearer header. A successful response contains:

```json
{
  "sub": "123",
  "email": "person@example.com",
  "email_verified": true,
  "name": "Example User",
  "org_id": 456
}
```

`sub` is the stable user identifier; `org_id` is the organization selected during consent. The endpoint requires both identity scopes and returns `403` if either is missing. It returns `403` with `error=access_denied` if the account has no verified email profile, rather than asserting that an unverified email is trusted. Invalid, expired, revoked, or wrong-audience tokens return `401`. Human session tokens and organization API keys cannot call userinfo. No ID token is issued.

## Available permissions

| Family | Scopes |
| --- | --- |
| Agents and agent imports | `agents:read`, `agents:write` |
| Calls | `calls:read`, `calls:write` |
| Phone numbers and VoIP | `numbers:read`, `numbers:write` |
| Knowledge | `knowledge:read`, `knowledge:write` |
| Campaigns | `campaigns:read`, `campaigns:write` |
| Integrations, webhook endpoints, MCP servers | `integrations:read`, `integrations:write` |
| Voices | `voices:read` |
| Test scenarios, test runs, validation | `testing:read`, `testing:write` |
| Billing | `billing:read` |
| Account identity and verified email | `openid`, `email` (both required for userinfo) |
| Persistent connection | `offline_access` (always included) |

GET, HEAD, and OPTIONS use read scopes; other methods use write scopes. Voice and billing mutations are unavailable through OAuth; `POST /v1/voices/preview` uses `voices:read` because it previews a voice without changing its configuration. MCP tools enforce the scope of their underlying REST operation. Cross-organization transfers are denied even when the authorizing user belongs to both organizations. Other API families, including API-key management and human account settings, are unavailable through OAuth. A missing permission on a REST operation returns `403` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`. Invalid or expired access tokens return `401`.


### MCP tool authentication signals

Every tool in `tools/list` includes `securitySchemes: [{"type": "oauth2", "scopes": ["agents:read"]}]`, with the scope of that tool's REST operation. Public documentation tools use an empty scope list and still require an authenticated connection.

A valid token missing a tool's scope receives HTTP `200` with a JSON-RPC `result` containing `isError: true`, explanatory text in `content`, and `_meta["mcp/www_authenticate"]`. The latter is an array containing a Bearer challenge with `resource_metadata`, `error="insufficient_scope"`, `error_description`, and the required `scope`. The tool is not executed. Use this challenge to request expanded consent. Missing or invalid authentication continues to return HTTP `401` with `WWW-Authenticate`; REST scope failures continue to return HTTP `403`.

## Credential retention

The API runs `python manage.py oauth_cleanup` hourly in staging and production. It removes expired authorization requests and device codes. Expired access tokens, authorization codes and refresh-token hashes are pruned only after their grant is revoked or the entire family is dead. Spent hashes are retained while the family has a usable refresh token, authorization code, approved device code or access token, so cleanup cannot disable replay detection.
