twitterapi.io is an independent third-party service. Not affiliated with X Corp.

Blogtwitter api oauth 2 setup

Twitter (X) API OAuth 2.0 User-Context — Setup + PKCE Guide

By Alex Chen7 min read

Every write operation on the X API — deleting a tweet, unliking, unretweeting, blocking, following — requires OAuth 2.0 user-context authentication. There is no way around it: X removed OAuth 1.0a support for most write endpoints in the 2023-2024 API consolidation, and the app-only Bearer token flow (Client Credentials) grants no user context at all. If you've been searching for twitter api oauth 2 setup, x api oauth 2.0 user context, twitter oauth 2 pkce, or oauth 2.0 twitter python — all the same underlying setup, all lead here.

The trap most developers hit: X's OAuth 2.0 requires PKCE (Proof Key for Code Exchange). The old-style client_secret-only flow does NOT work for user-context — X's authorization endpoint returns invalid_request if you omit code_challenge + code_challenge_method. This isn't documented as prominently as it should be, so plenty of tutorials from 2022-2023 still show the plain flow and lead you down a dead-end.

This guide is the working 2026 setup: developer app config (with the exact callback URL + scope pattern that works), the full PKCE flow with runnable Python via tweepy, refresh-token renewal, and the top 5 gotchas that eat a full afternoon each if you hit them cold. Read it before writing any tweet-delete / unlike / unretweet / block code.

01 — Section

Step 1 — create the X developer app (2 gotchas)

Go to developer.x.com → Projects & Apps → create a new app inside a Project (loose apps outside a Project can't access the v2 API — a hard requirement not always visible).

Gotcha 1 · App type must be 'Web' or 'Native', NOT 'Automated'. 'Automated' apps get app-only OAuth 1.0a Bearer authentication that can't do user-context writes. If you accidentally created 'Automated', edit the app settings — the type is changeable.

Gotcha 2 · Callback URL must be an exact match. Set it to your OAuth callback (localhost during dev: http://127.0.0.1:5000/callback; production: your actual HTTPS URL). Wildcards don't work. If your callback in code has a trailing slash and the registered one doesn't, you'll get invalid_request — invisible at the browser level, only shown in the raw redirect params.

Also set Website URL and Terms of Service URL — X will reject the OAuth flow without them (silent 400 on the authorize endpoint).

02 — Section

Step 2 — enable OAuth 2.0 + declare scopes

In the app's User authentication settings, toggle OAuth 2.0 on. The scopes you enable here define the maximum permissions the app can request; the actual token gets whatever subset the user consents to at authorize time.

Required scope patterns (per docs.x.com/x-api/authentication/oauth-2-0/authorization-code-flow-with-pkce):

- tweet.read — read tweets (needed even for write flows, since most write ops verify tweet existence first)

- tweet.write — post + delete tweets

- users.read — read user info (also required baseline)

- follows.read + follows.write — read/write following graph

- like.read + like.write — read likes / unlike

- offline.access — issue a refresh_token so you can renew without re-prompting the user

Gotcha 3 · Scope minimalism: request only what you need. A user consenting to tweet.write + like.write + follows.write is nervous; consenting to tweet.write alone is fine. Break your workflows into separate token scopes when possible — a 'delete-only' cleanup tool should not request tweet.write (which includes posting new tweets).

03 — Section

Step 3 — the PKCE flow, end-to-end

PKCE (Proof Key for Code Exchange, RFC 7636) is a two-round handshake designed to prevent authorization-code interception attacks. The flow:

1. Client generates a random code_verifier (43-128 chars, unreserved URL chars)

2. Client computes code_challenge = base64url(sha256(code_verifier))

3. Client redirects user to X's /oauth2/authorize with code_challenge + code_challenge_method=S256 + client_id + scope + redirect_uri + state

4. User consents; X redirects back to redirect_uri with code + state

5. Client POSTs /2/oauth2/token with code + code_verifier + redirect_uri + client_id + grant_type=authorization_code

6. X returns access_token (2h TTL) + refresh_token (long-lived) + scope

tweepy>=4.14 bundles all of this behind OAuth2UserHandler, which is what most production code uses.

python
# pip install tweepy>=4.14 requests
import tweepy, webbrowser, urllib.parse

CLIENT_ID = "YOUR_CLIENT_ID"
CLIENT_SECRET = "YOUR_CLIENT_SECRET"  # for confidential clients; public clients (mobile/SPA) omit
CALLBACK = "http://127.0.0.1:5000/callback"
SCOPES = ["tweet.read", "tweet.write", "users.read",
          "like.read", "like.write", "offline.access"]

# 1. Build authorize URL — tweepy handles PKCE code_verifier + challenge internally
handler = tweepy.OAuth2UserHandler(
    client_id=CLIENT_ID,
    redirect_uri=CALLBACK,
    scope=SCOPES,
    client_secret=CLIENT_SECRET,  # optional for public clients
)
auth_url = handler.get_authorization_url()
print("Open in browser + consent:")
print(auth_url)
webbrowser.open(auth_url)

# 2. After consent, the callback URL contains ?code=<...>&state=<...>
# Paste that FULL callback URL here (or run a local Flask handler to capture):
callback_response = input("Paste full callback URL after consent: ").strip()

# 3. Exchange code for tokens (tweepy sends code_verifier automatically)
token = handler.fetch_token(callback_response)
print("access_token:", token["access_token"][:20] + "...")
print("refresh_token:", token["refresh_token"][:20] + "...")
print("scope:", token["scope"])
print("expires_in:", token["expires_in"], "sec")

# 4. Use the access_token for a write call
client = tweepy.Client(bearer_token=token["access_token"])
# Now client.delete_tweet(id=...), client.unlike(tweet_id=...), etc. work.
04 — Section

Step 4 — refresh_token renewal (before every write batch)

Access tokens expire in ~2 hours. For a bulk-delete workflow that runs 4-6 hours, you MUST implement refresh. If you don't, calls start failing with 401 Unauthorized mid-batch and you have to re-prompt the user (bad UX in a scripted tool, impossible in a headless cron).

The refresh POST is a simple call to /2/oauth2/token with grant_type=refresh_token + refresh_token + client_id. Store both tokens; refresh proactively at ~90 minutes since issue (before expiry) to avoid mid-request failures.

python
import requests, time
from base64 import b64encode

def refresh_access(refresh_token: str, client_id: str,
                   client_secret: str = None) -> dict:
    """Exchange refresh_token for a new access_token."""
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    if client_secret:  # confidential client
        auth = b64encode(f"{client_id}:{client_secret}".encode()).decode()
        headers["Authorization"] = f"Basic {auth}"
    data = {
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "client_id": client_id,
    }
    r = requests.post("https://api.x.com/2/oauth2/token",
                      headers=headers, data=data, timeout=15)
    r.raise_for_status()
    return r.json()

class TokenStore:
    """Wraps auto-refresh for long-running batches."""
    def __init__(self, access, refresh, expires_at):
        self.access = access
        self.refresh = refresh
        self.expires_at = expires_at
    def get(self) -> str:
        if time.time() > self.expires_at - 300:  # 5-min buffer
            new = refresh_access(self.refresh, CLIENT_ID, CLIENT_SECRET)
            self.access = new["access_token"]
            self.refresh = new.get("refresh_token", self.refresh)
            self.expires_at = time.time() + new["expires_in"]
        return self.access

# Usage in a bulk-delete loop:
store = TokenStore(token["access_token"], token["refresh_token"],
                   time.time() + token["expires_in"])
for tweet_id in tweets_to_delete:
    client = tweepy.Client(bearer_token=store.get())
    client.delete_tweet(id=tweet_id)
    time.sleep(1.2)
05 — Section

Top 5 gotchas that eat an afternoon each

Gotcha A · invalid_request on authorize with no error detail. 90% of the time this means (a) callback URL mismatch (trailing slash, http vs https, port), or (b) app type is Automated instead of Web/Native, or (c) missing Website URL / TOS URL in app settings.

Gotcha B · Scope granted != scope requested. X returns scope in the token response — always check it matches what you asked for. Users can consent to a subset in the consent screen (rare but possible in some flows).

Gotcha C · Refresh token rotation. X sometimes returns a NEW refresh_token in the refresh response. If it does, your OLD refresh_token becomes invalid. Always update your stored refresh_token from every refresh response — don't assume it stays fixed. Code above handles this.

Gotcha D · Rate limit is per-token-per-endpoint per 15 minutes. OAuth 2.0 user-context tokens have separate rate limits from app-only Bearer. If you're mixing flows (read via Bearer + write via user token), the read + write budgets are independent — plan accordingly.

Gotcha E · offline.access scope is required for refresh_token. If you skip it, you get an access_token but NO refresh_token — the user has to re-consent every 2 hours. This is documented but easy to miss; always include offline.access unless you have a specific reason not to.

06 — Section

What comes after — connecting to write workflows

Once you have a valid access_token with the right scope, every write operation follows the same pattern: tweepy.Client(bearer_token=access) then call the method. For the workflow pages we've already published:

Delete tweets at /blog/delete-tweets-free-api-bulk-tutorial — scope: tweet.write

Bulk unlike + unretweet at /blog/bulk-unlike-unretweet-twitter-api-guide — scope: like.write + tweet.write

Media download at /blog/twitter-media-download-api-bulk-guide — read-only, uses Bearer token, no user-context needed

Set up OAuth 2.0 once with all the scopes you'll need across workflows; store the refresh_token securely (env var, secrets manager, or encrypted config); every write script imports the same TokenStore pattern shown above.

python
# End-to-end minimal working example: OAuth 2.0 PKCE setup + one delete call.
import tweepy, webbrowser, time, requests
from base64 import b64encode

CLIENT_ID = "YOUR_CLIENT_ID"
CLIENT_SECRET = "YOUR_CLIENT_SECRET"  # optional for public clients
CALLBACK = "http://127.0.0.1:5000/callback"
SCOPES = ["tweet.read", "tweet.write", "users.read", "offline.access"]

# 1. Get authorization URL
handler = tweepy.OAuth2UserHandler(
    client_id=CLIENT_ID,
    redirect_uri=CALLBACK,
    scope=SCOPES,
    client_secret=CLIENT_SECRET,
)
auth_url = handler.get_authorization_url()
print(f"Open in browser + consent, then paste the callback URL below.")
print(auth_url)
webbrowser.open(auth_url)

callback = input("Paste full callback URL (includes ?code=... &state=...):\n").strip()
token = handler.fetch_token(callback)
print(f"Got access_token (expires in {token['expires_in']}s), refresh_token, scope={token['scope']}")

# 2. Store for later refresh
issued_at = time.time()
expires_at = issued_at + token["expires_in"]
access = token["access_token"]
refresh = token["refresh_token"]

# 3. Use for a write op
client = tweepy.Client(bearer_token=access)
# client.delete_tweet(id="1234567890")  # ← now works with tweet.write scope

# 4. Auto-refresh helper for long-running batches
def ensure_fresh(access, refresh, expires_at):
    if time.time() < expires_at - 300:
        return access, refresh, expires_at
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    if CLIENT_SECRET:
        auth = b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()
        headers["Authorization"] = f"Basic {auth}"
    data = {"grant_type": "refresh_token", "refresh_token": refresh,
            "client_id": CLIENT_ID}
    r = requests.post("https://api.x.com/2/oauth2/token",
                      headers=headers, data=data, timeout=15)
    r.raise_for_status()
    new = r.json()
    return new["access_token"], new.get("refresh_token", refresh), \
        time.time() + new["expires_in"]

# In a loop:
# for tid in ids_to_delete:
#     access, refresh, expires_at = ensure_fresh(access, refresh, expires_at)
#     client = tweepy.Client(bearer_token=access)
#     client.delete_tweet(id=tid)
#     time.sleep(1.2)
07 — Questions

Questions readers ask

Do I really need OAuth 2.0 for delete tweets? Can I use OAuth 1.0a instead?

For 2026 X API, OAuth 2.0 user-context is the standard for write operations. X removed OAuth 1.0a support for most write endpoints during the 2023-2024 consolidation. Some legacy endpoints still accept OAuth 1.0a but the ecosystem (tweepy, official docs) has migrated. Set up OAuth 2.0 once — you'll use it for delete/unlike/unretweet/block/follow across every workflow.

Why does the authorize URL return invalid_request with no error detail?

Top 3 causes: (1) callback URL registered in developer.x.com doesn't exactly match the redirect_uri in your code (trailing slash, http vs https, port), (2) app type is 'Automated' instead of 'Web' or 'Native' (Automated = app-only OAuth 1.0a Bearer, no user-context), (3) missing Website URL or Terms of Service URL in app settings. Fix all 3 in developer.x.com and the flow works.

What's PKCE and why does X require it?

PKCE (Proof Key for Code Exchange, RFC 7636) prevents authorization-code interception. Client generates a random secret at flow start, sends the SHA-256 hash to the authorize endpoint, then sends the raw secret when exchanging code for tokens. Even if the redirect URL is intercepted, an attacker can't complete the token exchange without the original secret. X requires PKCE for OAuth 2.0 user-context flows to protect against public-client interception. tweepy>=4.14 handles it transparently.

How long do access tokens last? What about refresh tokens?

Access tokens: ~2 hours (7200 seconds). Refresh tokens: long-lived, but X may rotate them on refresh (return a new refresh_token in the response — always update your stored one). For any workflow lasting > 90 minutes (bulk delete, mass unlike, migration jobs), implement proactive refresh at 90-min mark to avoid mid-batch failures.

Can I skip the refresh_token entirely and re-authenticate each session?

Only for interactive scripts where the user is present. For headless cron jobs, background workers, or any 'run overnight' pattern, you MUST have a refresh_token — otherwise the token expires mid-batch and there's no user to re-consent. Always include offline.access in your scopes to get a refresh_token.

Do I need client_secret if my app is a public client (mobile/SPA)?

No. Public clients (mobile apps, SPAs, CLI tools distributed to users) can't safely store a client_secret — PKCE was designed exactly for this case. Set your app type accordingly in developer.x.com and omit client_secret from your code. The PKCE code_verifier is the substitute security mechanism.

What scopes should I request for a delete-all-tweets workflow?

Minimum: tweet.read + tweet.write + users.read + offline.access. Reason: tweet.write grants delete permissions (X uses one scope for post + delete on tweets); users.read is a baseline required for most endpoints; offline.access gets you the refresh_token; tweet.read is required because most workflows enumerate before deleting. Do NOT request write scopes you don't need — a user consenting to tweet.write alone is more likely to convert than one asked for the full write suite.

08 — Further reading

Continue

Sources & further reading
More from this series
Build it

Stop reading. Start building.

Starter credits cover real testing on real data. Google sign-in, no card, no application queue.

Get an API key
    Twitter (X) API OAuth 2.0 Setup — Full Guide | TwitterAPI.io