X API OAuth: pick the right auth for each endpoint
App-only, OAuth 1.0a or OAuth 2.0 with PKCE? The endpoints each one can reach, the scope table, token lifetimes, and the 2026 consent-screen change.
On this page · 8 sections
The short version
- ▸There are three methods and one question that picks between them: does the call need a specific user's permission? No means an app-only bearer token. Yes means OAuth 2.0 Authorization Code Flow with PKCE, or legacy OAuth 1.0a if you still have it wired.
- ▸The split is not read versus write. Filtered stream, volume stream and full-archive search accept app-only tokens ONLY, so a user token cannot reach them. Bookmarks accept user tokens only. Several endpoint groups accept one method and reject the other two.
- ▸OAuth 2.0 access tokens die after 2 hours. You only get a refresh token if you asked for the offline.access scope, and refresh tokens are single-use with roughly a 6-month life, so each refresh has to overwrite the stored one.
- ▸Since 21 September 2026 you can trade a stored OAuth 1.0a token for an OAuth 2.0 pair with a token-exchange grant: no consent screen, no user notification, scopes derived from what the user already allowed. X says OAuth 1.0a is being retired, with no published date.
- ▸Pay-per-use apps asking for any write scope or dm.read now show a 'Sensitive permissions requested' disclaimer and an 'I trust this app' checkbox on the consent screen. X states there is no way to remove it, so build your conversion expectations around it.
Quick answer
X API OAuth comes in three flavours and one question picks between them: does this call need a specific user's permission? If no, use an app-only OAuth 2.0 bearer token. If yes, use OAuth 2.0 Authorization Code Flow with PKCE and request the narrow scopes you need. OAuth 1.0a user context still works but X is retiring it, and since 21 September 2026 you can convert your stored 1.0a tokens to OAuth 2.0 silently with a token-exchange grant.
Last updated: October 2026
That is the whole decision in one paragraph. The reason X API authentication still eats a day of anyone's build is everything after the decision: several endpoint groups accept exactly one of the three methods, tokens expire on four different clocks, and the consent screen your users now see changed last month.
The three methods, and what each one actually is
| Method | Represents | Expires | Use it for |
|---|---|---|---|
| App-only bearer token | Your app | On revocation | Public reads, streams, archive search |
| OAuth 2.0 Authorization Code with PKCE | A consenting user | 2 hours | Anything on a user's behalf |
| OAuth 1.0a user context | A consenting user | On revocation | Legacy integrations, standalone Ads API |
The app-only token is the one you copy out of the Keys and Tokens page after you get an X API key. It is a static string, it carries app-level rate limits, and it cannot act as a person. You can also mint it yourself by passing your API key and secret to POST oauth2/token.
OAuth 2.0 with PKCE is the modern user path. You build an authorize URL pointing at x.com/i/oauth2/authorize, the user approves the scopes you listed, X redirects back with a code, and you trade that code for an access token. Picking fine-grained scopes is the point: the consent screen only shows what you asked for, so a reader app that requests tweet.read users.read presents a much easier decision than one asking for write access.
OAuth 1.0a is the signed-request method from the Twitter era. It still authenticates most v2 endpoints, and X's own token-exchange documentation now says plainly that it is being retired, so treat it as debt rather than a choice.
One detail that trips up server-side builds: your app type decides whether you get a client secret at all. X classifies Native App and Single Page App as public clients, and Web App and "Automated App or bot" as confidential clients. Only confidential clients receive a client secret and authenticate the token endpoint with an Authorization: Basic header; public clients must send client_id in the request body instead.
Which method each endpoint group accepts
This is the part the tutorials skip, and it is the part that costs you an afternoon. The split is not read versus write.
| Endpoint group | App-only | OAuth 2.0 user | OAuth 1.0a |
|---|---|---|---|
| Post and user lookup, recent search, timelines | yes | yes | yes |
| Filtered stream, volume stream | yes | no | no |
| Full-archive search | yes | no | no |
| Create or delete a post, repost, like, follow, mute | no | yes | yes |
| Hide a reply | no | yes | yes |
| Bookmarks lookup and management | no | yes | no |
| Spaces lookup and search | yes | yes | no |
| Space ticket buyers | no | yes | no |
Read that second row twice. The filtered stream and the 1% volume stream accept an app-only bearer token and refuse user tokens, so the natural instinct of "I have a user token, I will use it everywhere" fails on the exact endpoints a monitoring tool is built on. Most real integrations end up holding both kinds of credential at once: an app token for the streams and public reads, a per-user token for anything that writes.
Bookmarks run the other way. There is no app-only path, because a bookmark list is private by definition, so bookmark features need bookmark.read or bookmark.write from the account that owns them.
If you need a user's activity pushed to you rather than polled, that is a different system with its own permission model: the X Activity API splits events into public ones an app token can subscribe to and private ones that need the user's OAuth consent plus a matching scope.
The scope list
Scopes are granular and additive. The practical set, as published in X's OAuth 2.0 reference:
- Reads:
tweet.read,users.read,users.email,follows.read,like.read,list.read,mute.read,block.read,bookmark.read,dm.read,space.read,broadcast.read - Writes:
tweet.write,tweet.moderate.write,follows.write,like.write,list.write,mute.write,block.write,bookmark.write,dm.write,media.write,broadcast.write - Session:
offline.access, which is the only way to get a refresh token
Two rules save debugging time. Almost every user-context endpoint wants tweet.read and users.read in addition to the specific scope, even when it does nothing with posts. And offline.access is not optional in practice: without it your integration dies two hours after the user walks away, because there is nothing to refresh with.
Token lifetimes: four different clocks
| Credential | Lifetime | Reusable |
|---|---|---|
| Authorization code | 30 seconds | No |
| OAuth 2.0 access token | 2 hours | Yes, until expiry |
| OAuth 2.0 refresh token | About 6 months | No, single use |
| App-only bearer, OAuth 1.0a token | Until revoked | Yes |
A callback handler that does any slow work before the token exchange fails intermittently in production and never in local testing, because of that 30-second code window. Exchange first, then do your database writes.
The single-use refresh token is the other classic bug. Each refresh returns a new refresh token and invalidates the one you sent, so a worker that keeps the original in memory and refreshes twice logs the user out. Overwrite the stored value inside the same transaction that stores the new access token.
Callback URLs need exact-match validation, which means a trailing slash difference is a rejected request rather than a redirect. And on the upside, OAuth 2.0 raises the per-app ceiling for post lookup and user lookup from 300 to 900 requests per 15 minutes, which is a genuine reason to migrate a read-heavy integration.
Migrating OAuth 1.0a users with no consent screen
Added 21 September 2026, and the most useful auth change of the year if you maintain an older app. You can trade each stored OAuth 1.0a token for an OAuth 2.0 pair without the user doing anything:
curl --request POST 'https://api.x.com/2/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: Basic <base64(client_id:client_secret)>' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'subject_token=<oauth1_access_token>' \
--data-urlencode 'subject_token_type=urn:x:params:oauth:token-type:oauth1_token' \
--data-urlencode 'oauth_token_secret=<oauth1_token_secret>'
What X documents about the behaviour, and it is worth knowing before you script a million of these:
- Scopes are derived from the permission level the user originally approved. Read maps to the read scopes, read-and-write adds the write scopes, the DM permission adds
dm.readanddm.write, andoffline.accessis always included. You can request a subset, never more. - Scopes the original authorization never covered, such as
bookmark.readorspace.read, are not granted. Those still need a real authorization flow. - No consent screen, no notification, no session change for the user.
- The OAuth 1.0a token stays valid, so the migration is non-destructive and you can run it at your own pace.
- Re-running the exchange for the same user is safe and returns a fresh pair, but it invalidates the previous pair. Do not run two migration jobs over the same users at once.
400 invalid_grantmeans that user revoked you or the token died. Do not retry; treat them as churn to re-acquire.- The exchange is limited to 10,000 requests per 15 minutes per app per source IP, which is about a million users a day from one worker.
One exception X calls out: the standalone Ads API at ads-api.x.com continues to use OAuth 1.0a, so keep that integration wired. Exchanged tokens do carry ads.read and ads.write for the v2 ads endpoints, and the X Conversion API path works from those.
The consent screen changed, and you cannot opt out
If your authorization rate dropped in September, this is probably why. On 14 September 2026 X added a disclaimer to the consent screen for pay-per-use apps requesting any write scope or dm.read. The user now sees a "Sensitive permissions requested" notice, a statement that your app is not affiliated with X, a reminder to confirm they trust the callback URL, and a mandatory "I trust this app" checkbox before the Authorize button does anything.
X's developer forum post is blunt about it: there is no way to remove the warning from a pay-per-use app, it is expected behaviour rather than a misconfiguration, and it does not affect token issuance or billing. The practical responses are to request the narrowest scope set that works, delay the write-scope request until the user has a reason to trust you, and put your real brand on the app so the callback URL matches what you promised. A domain that looks nothing like your product name is now a visible risk signal rather than a detail nobody reads.
Worth noting for the other side of this: users can review and remove any of these grants at any time, which is what X connected apps covers. A token your integration relies on can vanish between two requests.
Auth is not the hard part of automation
Getting the handshake right takes a day. What the handshake cannot do is make engagement automation cheap or sanctioned.
Scopes decide what you can call, not what X allows you to do with it. Automated posting, following and replying sit under X's automation rules, which require accountability for what your app does and the automated account label on accounts that post without a human. The API route is the sanctioned one, and it is metered: see X API pricing for what each read, write and webhook event costs now that pay-per-use replaced the old tiers, and X Bots API for the bot-account path that skips a login entirely.
That arithmetic is why plenty of growth work never goes near a token. Replying in your own voice, from your own session, on your own machine is a different trade: no API bill, no scope negotiation, and a smaller detection surface than a cloud tool calling in from a shared IP pool, though a browser-driven workflow is a gray area under those same rules rather than something anyone can call compliant. That is the lane X-Autopilot sits in.
Pick the token for the endpoint, ask for the fewest scopes that work, store the refresh token like it is a password, and the auth layer stops being something you think about.
Frequently asked
Answers indexed by Google + AI assistants.
What authentication does the X API use?+
Three methods, all still live in October 2026. OAuth 2.0 app-only (a bearer token tied to your app, no user involved) for public data. OAuth 2.0 Authorization Code Flow with PKCE for anything done on behalf of a user, with granular scopes. OAuth 1.0a user context, the legacy signed-request method, which X is retiring without a published date. Enterprise endpoints also use HTTP basic auth.
Is a bearer token the same as OAuth 2.0?+
It is one half of it. The bearer token you copy out of the Developer Console is app-only OAuth 2.0: it identifies your app and nothing else, so it can read public posts and users but cannot post, like, follow, or read a DM. The other half, Authorization Code Flow with PKCE, also hands you a bearer token, but that one represents a user who approved specific scopes.
How long does an X API access token last?+
An OAuth 2.0 user access token expires 2 hours after it is issued. The authorization code you exchange for it expires in 30 seconds. A refresh token lasts about 6 months and is single-use, so every refresh returns a new one that must replace the old record. App-only bearer tokens and OAuth 1.0a access tokens do not expire on a timer; they end when revoked.
Can I migrate OAuth 1.0a users to OAuth 2.0 without asking them again?+
Yes, since 21 September 2026. POST the user's stored OAuth 1.0a access token and token secret to /2/oauth2/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange and you receive an OAuth 2.0 access token plus refresh token for the same user. There is no consent screen and no notification, the granted scopes mirror the original permission level, and the OAuth 1.0a token keeps working afterwards.
Why does my app's consent screen show a scary warning?+
Because it requests a write scope or dm.read on a pay-per-use account. X added a disclaimer on 14 September 2026 that names the sensitive permissions, states the app is not affiliated with X, tells the user to check the callback URL, and requires an 'I trust this app' checkbox. X's developer forum post says this is expected behaviour and that pay-per-use apps cannot remove it.
Which scope do I need to post on behalf of a user?+
tweet.write, plus tweet.read and users.read, which nearly every user-context call needs alongside the specific permission. Hiding a reply needs tweet.moderate.write. Following needs follows.write. Uploading media needs media.write. Reading DMs needs dm.read and sending them needs dm.write, and both now trigger the sensitive-permissions disclaimer.
- X developer docs - 'OAuth 2.0 Authorization Code Flow with PKCE': the full scope table, the 2-hour access token life, the 30-second authorization code window, the confidential versus public client split by app type, and the raised 900-per-15-minute app lookup limit on OAuth 2.0 (accessed 13 October 2026)
- X developer docs - 'Migrating OAuth 1.0a user tokens to OAuth 2.0': the token-exchange grant, the permission-to-scope mapping table, the 10,000-per-15-minute exchange limit, the error table, and the statement that OAuth 1.0a is being retired (accessed 13 October 2026)
- X developer docs - 'X API v2 authentication mapping': the endpoint-by-endpoint chart of which of the three methods each v2 endpoint group accepts, and the required scopes per endpoint (accessed 13 October 2026)
- X Developers forum - 'OAuth consent screen update for apps requesting sensitive scopes' by @taycaldwell: the Sensitive permissions requested notice, the required 'I trust this app' checkbox for pay-per-use apps asking for write scopes or dm.read, and the statement that it cannot be removed (posted 14 September 2026)
- X API changelog - 'Migrate OAuth 1.0a user tokens to OAuth 2.0 with token exchange' entry dated 21 September 2026, confirming scopes are derived from the user's original authorization and that offline.access is always included (accessed 13 October 2026)
Product designer and indie hacker. Runs the agent on his own X account every day and writes up what the data shows, including when it's inconvenient.
Follow on X →