---
title: "OAuth for MCP clients"
description: "Reference for building an MCP client that signs users in to TwexAPI: discovery, dynamic client registration, PKCE, scopes, tokens, refresh, and revocation."
---

TwexAPI runs an OAuth 2.1 authorization server for its MCP server. MCP clients such as Claude, ChatGPT, and Cursor use it automatically. Read this page if you are building your own client or debugging one.

- Authorization code flow with PKCE (`S256` only).
- Public clients only: no client secret. Register with dynamic client registration (RFC 7591).
- Users sign in and approve on `https://twexapi.io/oauth/consent` with their TwexAPI account.
- Tokens are opaque and work only on `https://api.twexapi.io/mcp`. They are not TwexAPI API keys and cannot call the REST API.
- Calls are billed to the user's credits through their API key, so the user needs an active key with credits to approve.

If you only need a script or backend to call TwexAPI, use an [API key](/authentication) instead.

## Discovery

A request to `https://api.twexapi.io/mcp` without credentials returns `401` with:

```http
WWW-Authenticate: Bearer realm="twexapi", resource_metadata="https://api.twexapi.io/.well-known/oauth-protected-resource/mcp", scope="mcp:read mcp:write"
```

### Protected resource metadata

`GET https://api.twexapi.io/.well-known/oauth-protected-resource/mcp` (also served at `/.well-known/oauth-protected-resource`):

```json
{
  "resource": "https://api.twexapi.io/mcp",
  "authorization_servers": ["https://api.twexapi.io"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["mcp:read", "mcp:write"],
  "resource_name": "TwexAPI"
}
```

### Authorization server metadata

`GET https://api.twexapi.io/.well-known/oauth-authorization-server`:

```json
{
  "issuer": "https://api.twexapi.io",
  "authorization_endpoint": "https://api.twexapi.io/oauth/authorize",
  "token_endpoint": "https://api.twexapi.io/oauth/token",
  "registration_endpoint": "https://api.twexapi.io/oauth/register",
  "revocation_endpoint": "https://api.twexapi.io/oauth/revoke",
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "revocation_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": ["mcp:read", "mcp:write"],
  "authorization_response_iss_parameter_supported": true
}
```

<a id="scopes"></a>

## Scopes

| Scope | Grants |
| --- | --- |
| `mcp:read` | `explore` and `twexapi_request` (read-only endpoints). Always granted. |
| `mcp:write` | `twexapi_write` (posting tweets and replies). The user can decline it on the consent page. |

Request `mcp:read mcp:write` to offer both. Unknown scopes are ignored, and a request with no recognized scope asks for both. The token response's `scope` field tells you what the user granted.

## 1. Register the client

```http
POST https://api.twexapi.io/oauth/register
Content-Type: application/json

{
  "client_name": "My Agent",
  "redirect_uris": ["http://127.0.0.1:53682/callback"]
}
```

```json
{
  "client_id": "twx_client_...",
  "client_id_issued_at": 1792600000,
  "client_name": "My Agent",
  "redirect_uris": ["http://127.0.0.1:53682/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

| Field | Rules |
| --- | --- |
| `redirect_uris` | Required. 1 to 10 URIs, each one of: `https://`; `http://` on `localhost`, `127.0.0.1`, or `[::1]`; or a private-use scheme such as `cursor://`. No fragments or user info. |
| `client_name` | Optional, up to 100 characters. Shown on the consent page and in the user's **Authorized apps** list. |
| `token_endpoint_auth_method` | Optional. Only `none` is accepted. |
| `grant_types` | Optional. A subset of `authorization_code` and `refresh_token`. |
| `response_types` | Optional. Only `code`. |

Registration is limited to 30 clients per IP address per hour. A client registration lasts 180 days and is extended every time a user starts an authorization with it, so register once and reuse the `client_id`.

## 2. Create a PKCE pair

Node.js:

```js
import crypto from "node:crypto";

const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
```

Python:

```python
import base64, hashlib, secrets

verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
```

The verifier must be 43 to 128 characters from `A-Z a-z 0-9 - . _ ~`.

## 3. Send the user to authorize

Open this URL in the user's browser:

```txt
https://api.twexapi.io/oauth/authorize
  ?response_type=code
  &client_id=twx_client_...
  &redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback
  &code_challenge=CHALLENGE
  &code_challenge_method=S256
  &state=RANDOM_STATE
  &scope=mcp%3Aread%20mcp%3Awrite
  &resource=https%3A%2F%2Fapi.twexapi.io%2Fmcp
```

| Parameter | Required | Notes |
| --- | --- | --- |
| `response_type` | Yes | `code`. |
| `client_id` | Yes | From registration. |
| `redirect_uri` | Yes | Must match a registered URI exactly. Loopback `http` URIs may use any port. |
| `code_challenge`, `code_challenge_method` | Yes | `S256` only. |
| `state` | Recommended | Returned unchanged; check it to prevent CSRF. |
| `scope` | No | See [Scopes](#scopes). |
| `resource` | No | If sent, must be `https://api.twexapi.io/mcp`. |

TwexAPI redirects the user to the consent page at `twexapi.io`. The user signs in, reviews the app name and the host they will return to, chooses whether to grant write access, and approves or denies. The request expires after 10 minutes.

## 4. Receive the code

On approval, the browser returns to your redirect URI:

```txt
http://127.0.0.1:53682/callback?code=CODE&state=RANDOM_STATE&iss=https%3A%2F%2Fapi.twexapi.io
```

Check that `state` matches and that `iss` is `https://api.twexapi.io`. On denial, the redirect carries `error=access_denied` instead of `code`. The code is single use and expires after 2 minutes.

If `client_id` is unknown or `redirect_uri` is not registered, TwexAPI shows a `400` error instead of redirecting, so a misconfigured client never sends users to an unverified address.

## 5. Exchange the code

```http
POST https://api.twexapi.io/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=CODE
&redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback
&client_id=twx_client_...
&code_verifier=VERIFIER
```

```json
{
  "access_token": "twx_at_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "twx_rt_...",
  "scope": "mcp:read mcp:write"
}
```

`redirect_uri` and `client_id` must match the authorization request. You may also send `resource=https://api.twexapi.io/mcp`. A JSON body is accepted as well.

## 6. Call the MCP server

```http
POST https://api.twexapi.io/mcp
Authorization: Bearer twx_at_...
Content-Type: application/json
Accept: application/json, text/event-stream
```

The access token works only on `/mcp`. An expired, revoked, or unknown token returns `401` with `error="invalid_token"` in the `WWW-Authenticate` header; refresh it or ask the user to sign in again.

## Refresh tokens

```http
POST https://api.twexapi.io/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=twx_rt_...
&client_id=twx_client_...
```

Each refresh returns a new access token and a new refresh token, and the old refresh token stops working. Store the new pair every time. If an old refresh token is used again, TwexAPI treats it as leaked and revokes the whole authorization, so every token from it stops working and the user has to sign in again.

## Revocation

```http
POST https://api.twexapi.io/oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=twx_rt_...
```

Revoking a refresh token ends the whole authorization. Revoking an access token ends only that token. The endpoint always returns `200`, even for unknown tokens. Users can also revoke any app under **Authorized apps** in the [dashboard](https://twexapi.io/dashboard), which takes effect immediately.

## Token lifetimes

| Item | Lifetime |
| --- | --- |
| Authorization request (consent page) | 10 minutes |
| Authorization code | 2 minutes, single use |
| Access token | 1 hour |
| Refresh token | 30 days, rotated on every use |
| Authorization not used for 30 days | Expires |
| Client registration | 180 days, extended on each authorization |

## Errors

Errors use the standard OAuth shape:

```json
{
  "error": "invalid_grant",
  "error_description": "The authorization code is invalid, expired or already used"
}
```

| Endpoint | `error` | Cause |
| --- | --- | --- |
| Register | `invalid_redirect_uri` | A redirect URI is missing, malformed, or uses a disallowed scheme or host. |
| Register | `invalid_client_metadata` | Unsupported `token_endpoint_auth_method`, `grant_types`, or `response_types`. |
| Register | `temporarily_unavailable` (`429`) | Too many registrations from your IP address. Reuse your `client_id`. |
| Authorize | `invalid_client` (`400`, no redirect) | Unknown `client_id`. Register first. |
| Authorize | `invalid_request` (`400`, no redirect) | `redirect_uri` is not registered for this client. |
| Authorize | `unsupported_response_type` | `response_type` is not `code`. |
| Authorize | `invalid_request` | Missing or invalid PKCE parameters. |
| Authorize | `invalid_target` | `resource` is not `https://api.twexapi.io/mcp`. |
| Authorize | `access_denied` | The user denied access. |
| Token | `invalid_grant` | Code or refresh token is invalid, expired, already used, or issued to another client; PKCE verification failed; or the authorization was revoked. |
| Token | `invalid_target` | `resource` does not match. |
| Token | `unsupported_grant_type` | Use `authorization_code` or `refresh_token`. |

The consent page also stops the user before approval when their account has no usable API key or no credits left, so your client never receives a token that cannot make calls.
