# X API OAuth 2.0 with PKCE, explained

To post for someone through X's API, your app needs a **user access token** for their account. OAuth 2.0's
authorization-code flow with PKCE gets that token after they authorize your app. An app-only bearer token cannot post
for a user, even though both tokens are sent in an `Authorization: Bearer …` header.

Checked on October 26, 2026 against X's own documentation, linked under [Sources](#sources), and Flaghand's current
implementation. No live X API calls were made. The examples below are request shapes, not captured credentials or
responses; don't paste real tokens, authorization codes or client secrets into shared logs.

## Pick the right client type

Enable OAuth 2.0 in your app's X Developer Console settings and register the callback URL before starting.

| Your app | Client type | At the token endpoint |
|---|---|---|
| Native app or browser-only single-page app | Public: it cannot keep a client secret | Send `client_id` in the form body; no client secret |
| Web app with a backend that can protect credentials | Confidential | Authenticate with HTTP Basic using the OAuth 2.0 Client ID and Client Secret |

PKCE is still used by a confidential client. It proves the token exchange belongs to the same authorization attempt;
the client secret authenticates the app itself. Never embed that secret in browser JavaScript or a mobile binary.
Use the **OAuth 2.0** client credentials, not the OAuth 1.0a API Key and API Key Secret.

## Ask for the scopes posting actually needs

X's [authentication mapping](https://docs.x.com/fundamentals/authentication/guides/v2-authentication-mapping) lists
`tweet.read tweet.write users.read` for `POST /2/tweets`. Add refresh and upload access for a publishing app:

| Scope | What it is for |
|---|---|
| `tweet.read` | Reading posts; also in X's required scope set for creating a post |
| `tweet.write` | Creating and deleting posts on the user's behalf |
| `users.read` | Reading account identity; also required for creating a post |
| `offline.access` | Receiving a refresh token, so the app can renew access without another consent screen |
| `media.write` | Uploading media through X's v2 media API |

A text-posting app that needs to stay connected therefore requests `tweet.read tweet.write users.read offline.access`.
Include `media.write` if it uploads images or video. Requesting a scope is not the same as receiving it: inspect the
returned `scope` and store what was granted. If you add a scope later, the user must authorize the expanded request;
refreshing an old grant doesn't add permissions.

## The flow, in five steps

### 1. Make a verifier, its S256 challenge, and state

For each authorization attempt, generate a new cryptographically random **code verifier** and a separate random
**state**. Keep both tied to that attempt until the callback. The verifier stays with the app; its SHA-256 hash, encoded
as base64url without padding, goes to X as `code_challenge`.

This Node.js example constructs the authorization URL without calling X:

```js
import { randomBytes, createHash } from 'node:crypto';

const verifier = randomBytes(48).toString('base64url');
const state = randomBytes(24).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const params = new URLSearchParams({
  response_type: 'code',
  client_id: 'YOUR_OAUTH2_CLIENT_ID',
  redirect_uri: 'https://example.com/oauth/x/callback',
  scope: 'tweet.read tweet.write users.read offline.access media.write',
  state,
  code_challenge: challenge,
  code_challenge_method: 'S256',
});
const authorizationUrl = 'https://x.com/i/oauth2/authorize?' + params;
// Save verifier and state securely for this attempt, then redirect the user's browser.
```

A verifier is 43 to 128 unreserved characters; this example makes 64. `base64url` is not ordinary Base64: no `+`, `/`
or trailing `=`. X's guide also shows `plain`; use `S256` rather than copying its static example challenges.

### 2. The user authorizes the app at X

Open `authorizationUrl` in their browser. X shows the requested permissions. The user may accept or decline; declining
is not a successful connection. PKCE isn't permission to skip this consent.

### 3. Check the callback before exchanging its code

After acceptance, X redirects to the registered callback with `code` and `state`. Match `state` to the saved attempt
before using the code, and retrieve **that attempt's** verifier. Don't use one global verifier that concurrent logins
can overwrite. Handle an error response without trying to redeem it as a code.

The `redirect_uri` must exactly match a registered callback. Use the same value in the authorization URL and token
exchange: scheme, host, port, path, query and trailing slash matter. `localhost` and `127.0.0.1` aren't interchangeable.
Encode each parameter once; use a URL/form encoder rather than concatenating unescaped values.

### 4. Exchange the code promptly

Send a form-encoded `POST` to `https://api.x.com/2/oauth2/token`. X documents a **30-second** code-redemption window.
The code is not a reusable credential: exchange it once, rather than replaying the callback.

For a **public client**, the request body contains:

```text
grant_type=authorization_code
client_id=YOUR_OAUTH2_CLIENT_ID
code=CODE_FROM_CALLBACK
redirect_uri=https://example.com/oauth/x/callback
code_verifier=THE_SAVED_VERIFIER
```

These lines list fields, not a literal wire body. Serialize them with `URLSearchParams` (joined by `&` and correctly
percent-encoded), and set `Content-Type: application/x-www-form-urlencoded`; don't send JSON.

For a **confidential client**, send the same grant, code, redirect and verifier, authenticated with
`Authorization: Basic BASE64_OF_CLIENT_ID_COLON_CLIENT_SECRET`. X says `client_id` isn't needed in the body when the
valid Basic header supplies it. Basic here is ordinary Base64, not the base64url used for PKCE.

Store the returned `access_token`, `expires_in`, granted `scope` and, when issued, `refresh_token` securely. Calculate
an expiry from `expires_in`; don't infer lifetime from a token's appearance. The user access token then goes in the
Bearer header on API requests. [Threads, polls and media](/docs/guides/x-api-threads-polls-media) shows the posting
request shapes, and [X API pricing](/docs/guides/x-api-pricing) explains their costs.

### 5. Refresh and replace, not refresh and forget

X's user access tokens normally last **two hours**. `offline.access` gets you a refresh token; it does **not** make the
access token permanent. Renew through the same token endpoint, form-encoded:

```text
grant_type=refresh_token
refresh_token=THE_CURRENT_REFRESH_TOKEN
```

A public client adds `client_id`; a confidential client uses its Basic header. No authorization code or PKCE verifier
is part of this refresh request.

**Refresh tokens rotate. Save the newest refresh token alongside the new access token and expiry before using the
new pair.** X's token-exchange documentation describes refresh tokens as single-use, and its Ads MCP guide explicitly
says to save each replacement. Don't let two workers refresh the same user's grant independently: coordinate the
refresh and make credential replacement atomic in your store. An old worker must not overwrite a newer pair.

The PKCE overview doesn't give a refresh-token lifetime. X's separate OAuth 1.0a-to-2.0 migration guide says about six
months for the tokens it issues; don't turn that into a guaranteed lifetime for every PKCE grant. Revocation can end
access sooner. If refresh is rejected, check the error and stored generation; a revoked or unusable grant needs fresh
user authorization, not an endless retry of the old refresh token.

## Common errors: what to check first

X's token-exchange documentation names `invalid_request`, `unauthorized_client` and `invalid_client`, but that error
table covers the separate OAuth 1.0a migration grant. It is not a promise that every PKCE failure has the same HTTP
status or wording. For PKCE, check the fields in X's authorization and user-token guides:

| Symptom | First checks |
|---|---|
| `invalid_request` | Correct endpoint and `grant_type`; form encoding, not JSON; required fields present. Authorization needs `response_type=code`, scope, state, challenge and method. Code exchange needs `code`, the original `redirect_uri` and `code_verifier`. A public client also needs `client_id`. |
| `unauthorized_client` or `invalid_client` | OAuth 2.0 is enabled on this app; credentials belong to that app; Client ID isn't an OAuth 1.0a API Key. Public client: body `client_id`, no invented secret. Confidential client: valid Basic header with its Client ID and Client Secret. |
| Redirect URI mismatch | The registered callback, authorization `redirect_uri` and exchange `redirect_uri` are identical, including any trailing slash. Check production versus development host and single percent-encoding. Don't replace the URI with an unregistered one to get past the check. |
| Expired/reused code or a verifier rejection, sometimes reported as `invalid_grant` | Redeem within the documented 30 seconds; use the original verifier, not its hash; hash it to reproduce the original challenge. Start a fresh authorization attempt if the code is no longer usable. |
| No `refresh_token` | The user authorized `offline.access` on this grant, not just on a later URL you haven't opened. |
| API call fails after token exchange | Use the user token, not an app-only bearer token; check granted scopes, expiry and the endpoint's access requirements. OAuth success alone doesn't prove an API call is permitted or funded. |

For diagnostics, keep the error name, status and parameter **names**, not client secrets, token values, the full callback
query or token response. Never paste credentials into an online Base64 decoder.

## How Flaghand uses it: two different OAuth connections

Flaghand's X connection uses S256 PKCE with a per-attempt verifier and state, a confidential-client Basic token
exchange, and `tweet.read tweet.write users.read media.write offline.access list.read`. `list.read` is extra to the
posting recipe above. It calculates expiry from X's `expires_in`, saves the replacement refresh token when X returns
one, and encrypts the stored platform credentials. This was checked against the implementation, not a new live login;
it is not a claim that public onboarding is open.

**An agent connecting to Flaghand is a different flow.** Flaghand is the authorization server for that MCP connection,
not X. It also uses S256, but issues a Flaghand workspace token limited to `read`, `drafts`, `media` and
`request-approval`; those tokens don't expire and have no refresh grant. They are revocable and cannot be used directly
against X. The owner-facing X connection holds X's tokens separately.

An X `tweet.write` grant gives an app technical posting power. It does not express approval of a particular draft.
Flaghand puts that decision above OAuth: an agent drafts, checks and requests approval; only the owner approves the
exact snapshot with a passkey. No agent token can approve, schedule or post. Read the
[agent documentation](https://app.flaghand.com/docs/agents) and [approval boundary](/docs/guides/let-an-ai-agent-post-safely).
[Get access to Flaghand](/schedule).

## Sources

All checked on October 26, 2026. No live X API calls were made.

- X: [authorization code with PKCE](https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code)
  — app types, scopes, exact callbacks, code-redemption window and access-token lifetime.
- X: [OAuth 2.0 user access tokens](https://docs.x.com/fundamentals/authentication/oauth-2-0/user-access-token)
  — public/confidential token exchange, Basic authentication, state, refresh and revocation.
- X: [v2 authentication mapping](https://docs.x.com/fundamentals/authentication/guides/v2-authentication-mapping)
  — the three scopes for `POST /2/tweets`.
- X: [upload media](https://docs.x.com/x-api/media/upload-media) — the v2 upload's `media.write` requirement.
- X: [OAuth 1.0a token exchange](https://docs.x.com/fundamentals/authentication/oauth-2-0/oauth-1-0a-token-exchange)
  — separate migration flow, single-use refresh tokens and its error table, not a PKCE-specific error contract.
- X: [Ads MCP](https://docs.x.com/x-ads-api/mcp) — another documented use of the same token endpoint, explicitly
  requiring replacement of rotating refresh tokens; its Ads scopes are not this guide's posting scopes.
- [RFC 7636, sections 4.1–4.3](https://www.rfc-editor.org/rfc/rfc7636.html#section-4.1) — verifier and S256 encoding.

[All guides](/docs/guides.md)
