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, 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 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:
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:
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 shows the posting
request shapes, and 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:
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 and approval boundary.
Get access to Flaghand.
Sources
All checked on October 26, 2026. No live X API calls were made.
- X: authorization code with PKCE — app types, scopes, exact callbacks, code-redemption window and access-token lifetime.
- X: OAuth 2.0 user access tokens — public/confidential token exchange, Basic authentication, state, refresh and revocation.
- X: v2 authentication mapping
— the three scopes for
POST /2/tweets. - X: upload media — the v2 upload's
media.writerequirement. - X: 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 — 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 — verifier and S256 encoding.