Tools
Tools: Fix: `xurl` OAuth 2.0 Fails with "unauthorized_client" on X API
2026-02-23
0 views
admin
Why It Happens ## The Fix ## Bonus: Why You Should Regenerate Credentials After Changing App Type ## Do Web Apps support OAuth2.0? If you're using xurl to authenticate with the X API and hitting this error: You're not alone — and the fix is a single setting in the X developer portal. xurl uses the OAuth 2.0 PKCE flow, which is designed for public clients (mobile apps, CLIs, SPAs). Public clients send credentials in the request body during token exchange. However, X API apps are created as Confidential Clients by default. Confidential clients require credentials to be sent as an Authorization: Basic header — a different mechanism that xurl doesn't use. When xurl sends a token exchange request without that header, X rejects it with unauthorized_client. You can confirm your app is a confidential client by base64-decoding your Client ID: If the decoded value ends in :ci, it's a confidential client. If it ends in :na, it's a native (public) client. Change your X app type from Web App to Native App in the developer portal: Then re-register fresh credentials and authenticate: After completing the browser-based consent flow, verify everything: When you change the app type, X issues a new Client ID with the :na suffix. Your old Client ID (with :ci) becomes invalid for PKCE flows, so make sure to copy the new values from the Keys and Tokens tab before re-registering with xurl. Change to Native App in the X developer portal, regenerate your credentials, and xurl's OAuth 2.0 flow will work as expected. Web App/Bot apps do support OAuth 2.0 — just not via xurl, because xurl only implements the public-client PKCE flow. Confidential clients require Authorization: Basic at the token exchange step, which no CLI tool currently handles for X. Here's how to do it manually with curl: Step 1 — Build the auth URL and open it in a browser: Open that URL in a browser, authorize, and grab the code=... value from the redirect URL. Step 2 — Exchange the code using Basic Auth: This returns an access_token you can then use directly with xurl: Web App/Bot + OAuth 2.0 is designed for server-side apps where the client secret never leaves your server. For local CLI use, Native App is the correct choice. OAuth 1.0a works with either app type and is often the path of least resistance for personal/development use. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
OAuth2 authentication failed: Auth Error: TokenExchangeError
(cause: oauth2: "unauthorized_client" "Missing valid authorization header") Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
OAuth2 authentication failed: Auth Error: TokenExchangeError
(cause: oauth2: "unauthorized_client" "Missing valid authorization header") CODE_BLOCK:
OAuth2 authentication failed: Auth Error: TokenExchangeError
(cause: oauth2: "unauthorized_client" "Missing valid authorization header") CODE_BLOCK:
echo "YOUR_CLIENT_ID" | base64 -d Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
echo "YOUR_CLIENT_ID" | base64 -d CODE_BLOCK:
echo "YOUR_CLIENT_ID" | base64 -d COMMAND_BLOCK:
# Re-register with your new credentials after regenerating them in the portal
xurl auth apps add my-app --client-id YOUR_NEW_CLIENT_ID --client-secret YOUR_NEW_CLIENT_SECRET # Store bearer token and OAuth 1.0a credentials while you're at it
xurl auth app --bearer-token YOUR_BEARER_TOKEN
xurl auth oauth1 \ --consumer-key YOUR_CONSUMER_KEY \ --consumer-secret YOUR_CONSUMER_SECRET \ --access-token YOUR_ACCESS_TOKEN \ --token-secret YOUR_ACCESS_TOKEN_SECRET # Set as default app
xurl auth default my-app # Now the OAuth 2.0 PKCE flow works
xurl --app my-app auth oauth2 Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# Re-register with your new credentials after regenerating them in the portal
xurl auth apps add my-app --client-id YOUR_NEW_CLIENT_ID --client-secret YOUR_NEW_CLIENT_SECRET # Store bearer token and OAuth 1.0a credentials while you're at it
xurl auth app --bearer-token YOUR_BEARER_TOKEN
xurl auth oauth1 \ --consumer-key YOUR_CONSUMER_KEY \ --consumer-secret YOUR_CONSUMER_SECRET \ --access-token YOUR_ACCESS_TOKEN \ --token-secret YOUR_ACCESS_TOKEN_SECRET # Set as default app
xurl auth default my-app # Now the OAuth 2.0 PKCE flow works
xurl --app my-app auth oauth2 COMMAND_BLOCK:
# Re-register with your new credentials after regenerating them in the portal
xurl auth apps add my-app --client-id YOUR_NEW_CLIENT_ID --client-secret YOUR_NEW_CLIENT_SECRET # Store bearer token and OAuth 1.0a credentials while you're at it
xurl auth app --bearer-token YOUR_BEARER_TOKEN
xurl auth oauth1 \ --consumer-key YOUR_CONSUMER_KEY \ --consumer-secret YOUR_CONSUMER_SECRET \ --access-token YOUR_ACCESS_TOKEN \ --token-secret YOUR_ACCESS_TOKEN_SECRET # Set as default app
xurl auth default my-app # Now the OAuth 2.0 PKCE flow works
xurl --app my-app auth oauth2 COMMAND_BLOCK:
xurl auth status
xurl --auth oauth2 /2/users/me # user context
xurl --auth oauth1 /2/users/me # also works
xurl --auth app "/2/tweets/search/recent?query=hello&max_results=5" # app-only Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
xurl auth status
xurl --auth oauth2 /2/users/me # user context
xurl --auth oauth1 /2/users/me # also works
xurl --auth app "/2/tweets/search/recent?query=hello&max_results=5" # app-only COMMAND_BLOCK:
xurl auth status
xurl --auth oauth2 /2/users/me # user context
xurl --auth oauth1 /2/users/me # also works
xurl --auth app "/2/tweets/search/recent?query=hello&max_results=5" # app-only COMMAND_BLOCK:
# Generate a code verifier + challenge (PKCE is optional but recommended even for confidential clients)
VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
CHALLENGE=$(echo -n "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16) CLIENT_ID="YOUR_CLIENT_ID"
REDIRECT="http://localhost:8080/callback" echo "https://x.com/i/oauth2/authorize?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT}&scope=tweet.read%20users.read%20offline.access&state=${STATE}&code_challenge=${CHALLENGE}&code_challenge_method=S256" Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# Generate a code verifier + challenge (PKCE is optional but recommended even for confidential clients)
VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
CHALLENGE=$(echo -n "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16) CLIENT_ID="YOUR_CLIENT_ID"
REDIRECT="http://localhost:8080/callback" echo "https://x.com/i/oauth2/authorize?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT}&scope=tweet.read%20users.read%20offline.access&state=${STATE}&code_challenge=${CHALLENGE}&code_challenge_method=S256" COMMAND_BLOCK:
# Generate a code verifier + challenge (PKCE is optional but recommended even for confidential clients)
VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
CHALLENGE=$(echo -n "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16) CLIENT_ID="YOUR_CLIENT_ID"
REDIRECT="http://localhost:8080/callback" echo "https://x.com/i/oauth2/authorize?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT}&scope=tweet.read%20users.read%20offline.access&state=${STATE}&code_challenge=${CHALLENGE}&code_challenge_method=S256" CODE_BLOCK:
CLIENT_ID="YOUR_CLIENT_ID"
CLIENT_SECRET="YOUR_CLIENT_SECRET"
CODE="CODE_FROM_REDIRECT"
VERIFIER="THE_VERIFIER_FROM_STEP_1" curl -X POST https://api.x.com/2/oauth2/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Authorization: Basic $(echo -n "${CLIENT_ID}:${CLIENT_SECRET}" | base64 -w 0)" \ -d "grant_type=authorization_code&code=${CODE}&redirect_uri=http://localhost:8080/callback&code_verifier=${VERIFIER}" Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
CLIENT_ID="YOUR_CLIENT_ID"
CLIENT_SECRET="YOUR_CLIENT_SECRET"
CODE="CODE_FROM_REDIRECT"
VERIFIER="THE_VERIFIER_FROM_STEP_1" curl -X POST https://api.x.com/2/oauth2/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Authorization: Basic $(echo -n "${CLIENT_ID}:${CLIENT_SECRET}" | base64 -w 0)" \ -d "grant_type=authorization_code&code=${CODE}&redirect_uri=http://localhost:8080/callback&code_verifier=${VERIFIER}" CODE_BLOCK:
CLIENT_ID="YOUR_CLIENT_ID"
CLIENT_SECRET="YOUR_CLIENT_SECRET"
CODE="CODE_FROM_REDIRECT"
VERIFIER="THE_VERIFIER_FROM_STEP_1" curl -X POST https://api.x.com/2/oauth2/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Authorization: Basic $(echo -n "${CLIENT_ID}:${CLIENT_SECRET}" | base64 -w 0)" \ -d "grant_type=authorization_code&code=${CODE}&redirect_uri=http://localhost:8080/callback&code_verifier=${VERIFIER}" CODE_BLOCK:
xurl -H "Authorization: Bearer ACCESS_TOKEN" /2/users/me Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
xurl -H "Authorization: Bearer ACCESS_TOKEN" /2/users/me CODE_BLOCK:
xurl -H "Authorization: Bearer ACCESS_TOKEN" /2/users/me - Go to developer.x.com/en/portal/dashboard
- Select your app → User authentication settings → Edit
- Change App type to Native App
- Set the Callback URI to http://localhost:8080/callback
how-totutorialguidedev.toaiserverssl