OAuth Troubleshooting

Twitter OAuth 2.0 refresh token expired?
It probably was not expiry. It was rotation.

X access tokens die every 2 hours, refresh tokens work exactly once, and two processes refreshing at the same moment kill each other. Here is the full failure model and the fix, with working Node.js and Python code.

7-day free trial • No credit card required

"Value passed for the token was invalid": what it actually means

The X API v2 OAuth 2.0 model has three rules that most tutorials skip. First, access tokens live for 2 hours (expires_in: 7200). Second, you only receive a refresh token if your authorization request included the offline.access scope. Third, and this is the one that bites: refresh tokens are single-use. Every successful refresh returns a brand new refresh token and permanently invalidates the one you just sent.

So when the token endpoint answers 400 invalid_request with "Value passed for the token was invalid", it is rarely because six months passed and the token aged out. It is almost always because the refresh token you sent was already consumed by another refresh: a second worker, a retry, an overlapping deploy, a cron job, or you testing the same token in Postman while production was running.

The other causes are quieter: the user revoked your app in their X settings, you regenerated your app's client secret in the developer portal (which invalidates issued tokens), or the token got mangled in transit because it was not URL-encoded in the form body. And yes, a refresh token that sits completely unused for months eventually does expire, but if your app posts even occasionally, that is not your bug.

refresh-request.sh
# The refresh call (public client)
curl -X POST https://api.twitter.com/2/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=refresh_token \
  -d refresh_token=$OLD_REFRESH_TOKEN \
  -d client_id=$X_CLIENT_ID

# Confidential clients authenticate with
# Basic auth (client_id:client_secret)
# instead of client_id in the body.
the-two-outcomes.json
// Success: a NEW pair. The old
// refresh token is now dead forever.
{
  "token_type": "bearer",
  "expires_in": 7200,
  "access_token": "NEW_ACCESS...",
  "refresh_token": "NEW_REFRESH...",
  "scope": "tweet.read tweet.write users.read offline.access"
}

// Failure: the token was already
// used, revoked, or malformed.
{
  "error": "invalid_request",
  "error_description": "Value passed for the token was invalid."
}

Fix X API refresh token invalid_request, step by step

1

Confirm you requested offline.access

If offline.access was not in your authorization URL's scope list, X never issued you a refresh token at all. Your access token silently dies at the 2 hour mark and there is nothing to refresh. Check the scope field in your original token response. If it is missing, the only fix is sending the user through the OAuth flow again with the scope added.

bash
# The scope parameter of your /2/oauth2/authorize URL must include it:
scope=tweet.read%20tweet.write%20users.read%20offline.access
2

Accept that refresh tokens rotate on every use

This is the mental model shift. A refresh token is not a long-lived credential you keep reusing. It is a one-time ticket that buys you a fresh access token plus the next ticket. Your token store is a chain, and the chain is only as alive as the newest link you managed to save. Treat the pair (access_token, refresh_token) as one atomic unit that gets replaced wholesale on every refresh.

A burned token never comes back

Retrying a failed refresh with the same token in a loop is pointless. Once X says invalid_request for a consumed token, that exact value will fail forever. The only recovery paths are: find the newer token some other process saved, or re-authorize the user.
3

Find the concurrent-refresh race condition

Here is the classic failure. Worker A and worker B both load refresh token R1 from the database. A refreshes first: it gets R2 and R1 dies. B then refreshes with the now-dead R1 and gets invalid_request. If B's error handler wipes or overwrites the stored tokens, or A crashed after refreshing but before saving R2, the chain is broken and the user has to reconnect their account.

Audit where refreshes can originate: multiple server instances, serverless functions scaling out, an overlapping old and new deployment, a scheduled job plus a web request, an aggressive retry wrapper, and a developer testing the stored token by hand. If more than one of those can run the refresh, you have this race.

4

Refresh proactively in Node.js

Do not wait for a 401 and refresh reactively; that is exactly when concurrent requests pile up and race. Instead, check the stored expiry before every call and refresh 5 minutes early behind a single-flight lock, so simultaneous callers await the same refresh promise instead of firing their own. Node 18+, no dependencies.

x-token-manager.mjs
import fs from "node:fs/promises";

const TOKEN_FILE = "./x-tokens.json";
const CLIENT_ID = process.env.X_CLIENT_ID;
const SKEW_MS = 5 * 60 * 1000; // refresh 5 minutes early

let inflight = null; // single-flight: concurrent callers share one refresh

export async function getAccessToken() {
  const tokens = JSON.parse(await fs.readFile(TOKEN_FILE, "utf8"));
  if (Date.now() < tokens.expires_at - SKEW_MS) {
    return tokens.access_token;
  }
  inflight ??= refresh(tokens).finally(() => { inflight = null; });
  return inflight;
}

async function refresh(tokens) {
  const res = await fetch("https://api.twitter.com/2/oauth2/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: tokens.refresh_token,
      client_id: CLIENT_ID,
    }),
  });
  if (!res.ok) {
    throw new Error(`Refresh failed ${res.status}: ${await res.text()}`);
  }

  const data = await res.json();
  const next = {
    access_token: data.access_token,
    refresh_token: data.refresh_token, // rotated: the old one is dead now
    expires_at: Date.now() + data.expires_in * 1000,
  };
  // Persist BEFORE returning. Crash after refresh but before this
  // write and your stored refresh token is already burned.
  const tmp = TOKEN_FILE + ".tmp";
  await fs.writeFile(tmp, JSON.stringify(next));
  await fs.rename(tmp, TOKEN_FILE);
  return next.access_token;
}
5

Refresh proactively in Python

Same strategy with requests: a lock so threads cannot race, an early-refresh window, and an atomic os.replace so a crash mid-write cannot leave you with half a token file.

x_token_manager.py
import json, os, time, threading
import requests

TOKEN_FILE = "x-tokens.json"
CLIENT_ID = os.environ["X_CLIENT_ID"]
SKEW = 300  # refresh 5 minutes early
_lock = threading.Lock()

def get_access_token() -> str:
    with _lock:
        with open(TOKEN_FILE) as f:
            tokens = json.load(f)

        if time.time() < tokens["expires_at"] - SKEW:
            return tokens["access_token"]

        r = requests.post(
            "https://api.twitter.com/2/oauth2/token",
            data={
                "grant_type": "refresh_token",
                "refresh_token": tokens["refresh_token"],
                "client_id": CLIENT_ID,
            },
        )
        r.raise_for_status()
        data = r.json()

        tokens = {
            "access_token": data["access_token"],
            "refresh_token": data["refresh_token"],  # rotated
            "expires_at": time.time() + data["expires_in"],
        }
        tmp = TOKEN_FILE + ".tmp"
        with open(tmp, "w") as f:
            json.dump(tokens, f)
        os.replace(tmp, TOKEN_FILE)  # atomic
        return tokens["access_token"]

Confidential clients: use Basic auth

If your X app is a confidential client (Web App, Automated App or Bot in the developer portal), authenticate the refresh request with an Authorization header of Basic base64(client_id:client_secret) instead of sending client_id in the body. Public clients (Native App) send client_id in the form body as shown above.
6

Persist first, and refresh in exactly one place

The in-process locks above only protect a single process. If you run multiple instances, move the lock to shared infrastructure: a Redis SET key value NX PX 30000, a Postgres advisory lock, or a MongoDB findOneAndUpdate that atomically claims a refreshing flag. Whoever loses the lock waits and re-reads the token store instead of calling the token endpoint. And always write the new pair to the database before using the new access token, never after.

When a refresh does fail with invalid_request, do not clear the stored tokens immediately. First re-read the store: in the race scenario, the winning process has often already saved a newer, valid pair. Only mark the connection as needing re-authorization if the freshest stored token also fails.

In Node, there is a plugin built solely for this problem

If you use the popular twitter-api-v2 library, @twitter-api-v2/plugin-token-refresher exists specifically to handle rotation: it refreshes preventively when it knows the token is expired, retries requests that hit 401 or 403 after refreshing, and gives you a hook to sync the new pair to your database. The fact that a dedicated npm package exists for this tells you how common the failure is.

with-token-refresher.mjs
import { TwitterApi } from "twitter-api-v2";
import { TwitterApiAutoTokenRefresher } from "@twitter-api-v2/plugin-token-refresher";

const refresher = new TwitterApiAutoTokenRefresher({
  refreshToken: tokenStore.refreshToken,
  refreshCredentials: {
    clientId: process.env.X_CLIENT_ID,
    clientSecret: process.env.X_CLIENT_SECRET,
  },
  onTokenUpdate(token) {
    // Persist immediately: the previous refresh token just died
    tokenStore.accessToken = token.accessToken;
    tokenStore.refreshToken = token.refreshToken;
  },
  onTokenRefreshError(error) {
    console.error("Refresh error", error);
  },
});

const client = new TwitterApi(tokenStore.accessToken, {
  plugins: [refresher],
});

await client.v2.tweet("Posted with auto-refresh.");

The plugin is single-process only

Its own documentation is explicit that concurrency handling works within one process only. Run two instances of your bot, or a bot plus a cron job, and you are back to the race condition from step 3. Multi-process setups still need the shared lock from step 6.

Or make refresh tokens someone else's problem

Everything above is real work you have to build, test, and keep running just so your app can stay logged in to X. OpenTweet takes the whole problem off your plate: it holds the X OAuth connection server-side, rotates tokens for you, and your code authenticates with one static ot_ key that never expires and never rotates. No token endpoint, no race conditions, no re-authorization emails from angry users, and no X developer account either.

You get an API key from the developer page, put it in an Authorization header, and post. The authentication docs fit on one screen because there is nothing to manage. See the full API posting guide for scheduling, threads, and media.

diy-oauth.txt
# What you maintain with raw X OAuth 2.0
1. OAuth authorize flow + PKCE
2. Token exchange endpoint
3. Encrypted token storage
4. Proactive refresh worker
5. Single-flight / distributed lock
6. Persist-before-use ordering
7. invalid_request recovery logic
8. Re-auth flow when the chain breaks
with-opentweet.mjs
// What you maintain with OpenTweet
await fetch("https://opentweet.io/api/v1/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.OPENTWEET_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "No refresh tokens were harmed.",
    publish_now: true,
  }),
});

Common Mistakes to Avoid

Retrying the refresh in a loop

A consumed refresh token never becomes valid again. Retrying the same value burns rate limit and buries the real signal. Re-read your token store for a newer pair, and if there is none, send the user back through OAuth.

Using the new token before saving it

The moment the refresh succeeds, the old refresh token is dead. If your process crashes between the refresh response and the database write, the connection is unrecoverable. Persist the pair first, then use it.

Refreshing from more than one place

A web server, a cron job, and a Postman tab sharing one token store is three racers for a single-use token. Route every refresh through one code path guarded by one lock, or one dedicated process.

Frequently Asked Questions

Why did my Twitter OAuth 2.0 refresh token expire?

Most of the time it did not expire, it was rotated. X refresh tokens are single-use: every successful refresh returns a new refresh token and kills the old one. If any other process, retry, cron job, or a Postman session refreshed first, the token in your database is already dead. Genuinely expired refresh tokens do exist too: X invalidates them if the user revokes the app, and after long periods of disuse.

What does 'Value passed for the token was invalid' mean?

X's token endpoint returns 400 invalid_request with this message when the refresh token you sent is not currently valid. The usual causes: it was already used once (rotation), the user revoked access, the token was truncated or not URL-encoded correctly, or you regenerated your app's client credentials which invalidates existing tokens.

How long do X API access tokens last?

Two hours (expires_in is 7200 seconds). After that every API call returns 401 until you exchange your refresh token for a new pair at POST https://api.twitter.com/2/oauth2/token with grant_type=refresh_token. You only get a refresh token if the original authorization requested the offline.access scope.

Can I use an X refresh token more than once?

No. Each refresh token works exactly once. The refresh response contains a brand new refresh_token that replaces it. If you do not persist that new value immediately, or two workers race to refresh the same token, you lose the chain and the user has to re-authorize from scratch.

How do I avoid managing X refresh tokens at all?

Put the OAuth connection behind a service that owns it. OpenTweet holds the X OAuth tokens server-side and handles rotation for you, and your code authenticates with one static ot_ API key that never expires and never rotates. One fetch call to https://opentweet.io/api/v1/posts posts a tweet, with no token endpoint, no race conditions, and no X developer account.

Never debug a token race again

One static ot_ key. OpenTweet handles the X OAuth connection, rotation included, on its side of the wire.