How to Connect Users’ X (Twitter) Accounts Using OAuth 2.0 + PKCE
If OAuth has ever felt like black magic, this post is for you.
How to Connect Users’ X (Twitter) Accounts Using OAuth 2.0 + PKCE
If OAuth has ever felt like black magic, this post is for you.
TL;DR: This tutorial shows you how to let users connect their X account to your app so you can post tweets on their behalf. By the end, you’ll understand OAuth conceptually and have working Node.js code.
Note: This is account linking, not primary login. Your app still needs its own auth (email/GitHub/etc.). We’re connecting X as a second account, not using it as the main login method.
What we’re building: A flow where users click “Connect X Account,” log into X, approve your app, and return — after which your app can tweet for them.
Prerequisites
Requirements:
- This tutorial uses JavaScript but easily replicable in any language
- Comfortable with require, async/await, Express routes
- Understanding of HTTP fundamentals (GET vs POST, query parameters, redirects)
- Understanding of what an API is ig…
You’ll also need:
- An X Developer Account with an app created
- Your app’s Client ID (and optionally Client Secret)
- A callback URL registered in your X app settings (e.g., http://localhost:3000/auth/x/callback))
Part 1: Understanding the Problem
The Challenge
You’re building an app that posts tweets for users. But you can’t just ask for their X password — duh:
- Insecure — You’d have access to their entire account
- Fragile — If they change their password, your app breaks
- Against X’s rules — They’ll ban you lol
The Solution: OAuth
OAuth lets users grant your app limited, revocable access without sharing passwords.
Think of it like a hotel key card:
- The hotel (X) gives you a card (access token)
- The card only opens your room (specific permissions you requested)
- The hotel can deactivate it anytime (user revokes access)
- You never get the master key (their password)
OAuth Flow in Plain English
Here’s what happens when a user clicks “Connect X Account”:
Step 1: User clicks “Connect with X” on your app
↓
Step 2: Your app redirects them to X’s login page
↓
Step 3: User logs into X (if not already) and sees:
“AiSlopProject wants to: Read your tweets, Post tweets for you”
↓
Step 4: User clicks “Authorize”
↓
Step 5: X redirects back to YOUR app with a temporary code
↓
Step 6: Your app exchanges that code for an access token
↓
Step 7: You store the token and use it for API calls
The user only ever enters their password on X’s website — never on yours.
The Full Flow (With PKCE)
Here’s the complete sequence from start to finish. You’ll probably refer back to this as we dive into each part:

OAuth 2.0 with PKCE
1. Your server creates:
-
state (CSRF protection — random string)
-
code_verifier (PKCE secret — random string)
-
code_challenge (hashed verifier — SHA256)
2. User is redirected to X with:
-
client_id (your app’s ID)
-
scopes (what permissions you want)
-
state (to verify later)
-
code_challenge (the hashed verifier)
3. User logs into X (if not already) and approves access
4. X redirects back to your app with:
-
authorization code (temporary, single-use)
-
same state (so you can verify it’s legit)
5. Your server verifies state matches
6. Your server exchanges:
- code + code_verifier
→ for access_token + refresh_token
7. Tokens are stored and used for API calls
Now that you’ve seen the full story, let’s zoom into each part and understand why each step exists.
Part 2: Why PKCE Exists
The Vulnerability
There’s a problem with the basic OAuth flow. In Step 5, X redirects back to your app with a code in the URL: https://yourapp.com/callback?code=abc123xyz
What if a malicious app intercepts this redirect? They’d have your authorization code and could exchange it for tokens — hijacking your user’s access.
PKCE Prevents This

Explicit PKCE flow within OAuth
PKCE (pronounced “pixie”) adds a secret handshake:
- Before Step 2: Your app generates a random secret called a code_verifier
- In Step 2: Your app sends a hashed version (code_challenge) to X
- In Step 5: Your app sends the original code_verifier to X
- X verifies: “The hash of what you just sent matches what I received earlier — you’re legit”
Even if an attacker intercepts the code, they can’t use it without the original code_verifier that never left your server. And they can’t invent one, X already locked the verifier hash to this specific authorization request.
Think of it like a sealed envelope. You show X a sealed envelope with a unique seal (the hash). Later, to prove you’re the same person, you must open the envelope and show what’s inside (the verifier). The seal can’t be reverse-engineered — you had to be the one who sealed it in the first place.
Why this matters
Without PKCE, a malicious app could steal the authorization code while it’s being sent to your app, then use it to get access tokens. PKCE prevents this by requiring proof that the same app that started the flow is the one finishing it.
Part 3: The Code, Explained File by File
We’ll separate concerns so each file has a single responsibility:
- One file for cryptography (PKCE generation)
- One file for OAuth protocol logic (token exchange, refresh)
- One file for HTTP routes (Express endpoints)
Simple, no?
This structure makes it easier to test, debug, and understand. Here’s how we organize it:
src/
├── pkceHelper.js - Generates the cryptographic secrets
├── oauthHandler.js - Manages the OAuth flow and tokens
└── server.js - Express routes for /auth/x and /auth/x/callback
Let’s walk through each one.
File 1: pkceHelper.js — The Cryptography
This file has one job: generate the code_verifier and code_challenge pair.
// pkceHelper.js - Minimal version
const crypto = require('crypto');
/*
Generate a random string that's safe to use in URLs.
This is your secret - NEVER expose it to the frontend.
*/
function generateCodeVerifier() {
// Create 96 random bytes, convert to base64url (128 chars)
return crypto.randomBytes(96).toString('base64url');
}
/*
Create a "fingerprint" of the verifier using SHA-256.
This is what you send to X - it can't be reversed back to the verifier.
*/
function generateCodeChallenge(verifier) {
return crypto
.createHash('sha256') // SHA-256 hash function
.update(verifier) // Hash the verifier
.digest('base64url'); // Output as URL-safe base64
}
// Generate both at once for convenience
function generatePKCEPair() {
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
return { verifier, challenge };
}
module.exports = { generateCodeVerifier, generateCodeChallenge, generatePKCEPair };
FAQ
1. Why base64url instead of regular base64?
Regular base64 uses +, /, and = characters. These have special meaning in URLs:
-
- becomes a space
- / is a path separator
- = is for query parameters
base64url replaces them: + → -, / → _, and removes = padding.
X will reject your request if you send regular base64 instead of base64url.
2. Why 96 bytes?
The OAuth spec requires the verifier to be 43–128 characters. 96 random bytes = 128 characters when base64url encoded. More entropy = harder to guess.
Production Note: Async Cryptography
In this tutorial, we use crypto.randomBytes() synchronously for simplicity. In high-throughput production servers, consider using the async version to avoid blocking the event loop
// Production alternative
const { promisify } = require('util');
const randomBytesAsync = promisify(crypto.randomBytes);
async function generateCodeVerifier() {
const bytes = await randomBytesAsync(96);
return bytes.toString('base64url');
}
File 2: oauthHandler.js — Managing the OAuth Dance
This file handles two things:
- Starting the flow: Building the URL where users authorize your app
- Finishing the flow: Exchanging the code for tokens
// oauthHandler.js - Simplified for learning
const { generatePKCEPair } = require('./pkceHelper');
class OAuthHandler {
constructor(clientId, clientSecret, redirectUri, scopes = null) {
this.clientId = clientId;
this.clientSecret = clientSecret; // Optional - only for "confidential" apps
this.redirectUri = redirectUri;
// X's OAuth endpoints
this.authUrl = 'https://twitter.com/i/oauth2/authorize';
this.tokenUrl = 'https://api.x.com/2/oauth2/token';
// What permissions we're asking for - can be customized per request
this.scopes = scopes || [
'tweet.read', // Read user's tweets
'tweet.write', // Post tweets for them
'users.read', // Get their profile info
'offline.access' // Get refresh tokens (critical for production!)
];
/* Note: 'offline.access' sounds scary (like "always-on access"), but it just means
"gimme a refresh token so I can get new access tokens without the user
having to log in again every 2 hours." Without it, users must re-authenticate
whenever their access token expires.
*/
}
/*
STEP 1: Generate the authorization URL.
Returns the URL AND the verifier (which you must store server-side).
*/
generateAuthUrl(state, customScopes = null) {
const { verifier, challenge } = generatePKCEPair();
const scopesToUse = customScopes || this.scopes;
// Build the URL users will visit
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope: scopesToUse.join(' '),
state: state, // CSRF protection (random string)
code_challenge: challenge, // The hashed verifier
code_challenge_method: 'S256' // "I used SHA-256 to hash it"
});
return {
url: `${this.authUrl}?${params.toString()}`,
verifier: verifier // Store this! You'll need it in Step 2.
};
}
/*
STEP 2: Exchange the authorization code for tokens.
Called after X redirects back to your app.
*/
async exchangeCodeForTokens(code, codeVerifier) {
// Build the token request
const params = new URLSearchParams({
grant_type: 'authorization_code',
code: code, // The code X gave us
redirect_uri: this.redirectUri,
code_verifier: codeVerifier // Proves we started this flow
});
// Handle "Confidential" vs "Public" clients
// If you were issued a Client Secret by X, you MUST use it via Basic Auth.
// If you're doing this entirely from a frontend (not recommended), you omit it.
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (this.clientSecret) {
// Confidential client: Use Basic Auth
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
} else {
// Public client: Include client_id in body
params.append('client_id', this.clientId);
}
// Make the request to X
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers,
body: params.toString()
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return response.json();
// Returns: { access_token, refresh_token, expires_in, scope, token_type }
}}
module.exports = OAuthHandler
FAQ
What’s state for?
state is a random string you generate before redirecting. When X redirects back, it includes the same state. You verify it matches — this prevents CSRF attacks where someone tricks a user into authorizing a malicious flow.
PKCE and state solve different problems:
- state = “Is this redirect legitimate?” (prevents CSRF)
- PKCE = “Is the app exchanging the code the same one that started the flow?” (prevents code interception)
Use both. Always.
What’s a “confidential” vs “public” client?
- Confidential client: Server-side app that can securely store a client_secret. X accepts Basic Authentication.
- Public client: Browser/mobile app where secrets would be visible in source code. PKCE alone provides security.
If you have a client_secret, X uses Basic Auth to verify you. If not, PKCE alone proves your identity.
File 3: server.js — The Express Routes
This is where everything comes together. Two routes:
- GET /auth/x — Start the OAuth flow
- GET /auth/x/callback — Handle X’s redirect
CRITICAL: Linking OAuth to Your Users
The biggest trap beginners fall into: You get the OAuth tokens back, but which user do they belong to? This section shows you how to properly link X accounts to your app’s logged-in users.
// server.js - OAuth routes with proper session handling
const express = require('express');
const session = require('express-session');
const crypto = require('crypto');
const OAuthHandler = require('./oauthHandler');
const app = express();
// Session middleware - CRITICAL for linking OAuth to users
app.use(session({
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// In-memory storage for PKCE verifiers
// In production, use Redis or your database
const pkceStore = new Map();
// Initialize the OAuth handler
const oauth = new OAuthHandler(
process.env.OAUTH_CLIENT_ID,
process.env.OAUTH_CLIENT_SECRET, // Can be undefined
'http://localhost:3000/auth/x/callback'
);
/*
ROUTE 1 (1/2): Start the OAuth flow
User clicks "Connect with X" → this redirects them to X's login
IMPORTANT: This route must only be accessible to LOGGED-IN users.
Check your app's session/auth here!
*/
app.get('/auth/x', (req, res) => {
// CRITICAL: Verify user is logged in to YOUR app first
if (!req.session.userId) {
return res.status(401).send('You must be logged in to connect an X account');
}
// 1. Generate a random state for CSRF protection
const state = crypto.randomBytes(16).toString('hex');
// 2. Generate the auth URL and PKCE verifier
const { url, verifier } = oauth.generateAuthUrl(state);
// 3. Store the verifier AND link it to the current user's session
pkceStore.set(state, {
verifier,
userId: req.session.userId, // CRITICAL: Link to current user
createdAt: Date.now()
});
// 4. Clean up old entries (prevent memory leaks)
const tenMinutesAgo = Date.now() - 10 * 60 * 1000;
for (const [key, value] of pkceStore.entries()) {
if (value.createdAt < tenMinutesAgo) {
pkceStore.delete(key);
}
}
// 5. Redirect user to X
res.redirect(url);
});
/*
ROUTE 2 (2/2): Handle X's callback
X redirects here after user approves → we exchange the code for tokens
CRITICAL: This is where you link the OAuth tokens to the correct user.
*/
app.get('/auth/x/callback', async (req, res) => {
const { code, state, error } = req.query;
// Handle user denying access
if (error) {
return res.send(`Authorization failed: ${error}`);
}
// Verify we have both code and state
if (!code || !state) {
return res.status(400).send('Missing code or state parameter');
}
// Look up the PKCE verifier we stored earlier
const pkceData = pkceStore.get(state);
if (!pkceData) {
// Common error: User hit "back" or refresh after completing OAuth
return res.status(400).send(
'Invalid or expired state. This often happens if you refreshed the page. ' +
'Please go back and click "Connect X Account" again.'
);
}
// CRITICAL: Verify the session still exists and matches
// This ensures the user receiving the tokens is the one who started the flow
if (!req.session.userId || req.session.userId !== pkceData.userId) {
pkceStore.delete(state);
return res.status(401).send('Session mismatch. Please log in again and retry.');
}
try {
// Exchange the code for tokens
const tokens = await oauth.exchangeCodeForTokens(code, pkceData.verifier);
// Clean up the PKCE store
pkceStore.delete(state);
/* CRITICAL: Store tokens associated with the correct user
In production, save to your database:
await db.storeOAuthToken(req.session.userId, {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: Date.now() + (tokens.expires_in * 1000),
scope: tokens.scope
*/ });
console.log(`User ${req.session.userId} connected X account`);
console.log('Tokens:', {
access_token: tokens.access_token.substring(0, 20) + '…',
refresh_token: tokens.refresh_token ? 'present' : 'missing',
expires_in: tokens.expires_in
});
res.send('Success! X account connected. You can close this window.');
} catch (err) {
console.error('Token exchange failed:', err);
res.status(500).send(`Failed to connect: ${err.message}`);
}
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
Why Session Linking Matters
The Trap: A beginner will copy basic OAuth code, get the tokens, and realize they have no idea which user those tokens belong to.
The Solution: Use your app’s session system to link the OAuth flow to the logged-in user:
- When starting OAuth: Store the user’s ID with the PKCE verifier
- In the callback: Check the session to ensure it’s the same user
- When saving tokens: Associate them with that user’s ID in your database
Key takeaway: The state parameter verifies the OAuth request is legitimate, but your session cookie tells you which user is making that request.
Part 4: The Token Lifecycle
X access tokens expire in ~2 hours. Without handling this, your app breaks every 2 hours.
This is where most OAuth tutorials stop and where most production bugs begin.
Token States

Refresh Token Code
/*
Get a new access token using the refresh token.
Call this when API requests return 401.
*/
async refreshAccessToken(refreshToken) {
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken
});
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (this.clientSecret) {
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
} else {
params.append('client_id', this.clientId);
}
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers,
body: params.toString()
});
if (!response.ok) {
throw new Error('Refresh failed - user must re-authenticate');
}
return response.json();
// Returns new access_token (and possibly new refresh_token)
}
When to Refresh
Option 1: Proactive — Check expires_at before each API call, refresh if expired
async function makeApiRequest(userId) {
let token = db.getToken(userId);
// Check if expired (with 5 min buffer)
if (Date.now() > token.expires_at - 5 * 60 * 1000) {
token = await oauth.refreshAccessToken(token.refresh_token);
db.updateToken(userId, token);
}
return fetch('https://api.x.com/2/tweets', {
headers: { 'Authorization': `Bearer ${token.access_token}` }
});
}
Option 2: Reactive — Try the request, refresh on 401, retry
async function makeApiRequest(userId) {
const token = db.getToken(userId);
let response = await fetch('https://api.x.com/2/tweets', {
headers: { 'Authorization': `Bearer ${token.access_token}` }
});
if (response.status === 401) {
const newToken = await oauth.refreshAccessToken(token.refresh_token);
db.updateToken(userId, newToken);
// Retry with new token
response = await fetch('https://api.x.com/2/tweets', {
headers: { 'Authorization': `Bearer ${newToken.access_token}` }
});
}
return response;
}
Part 5: Complete Working Example
This example prioritizes clarity over architecture. In real apps, you’ll split this into services, middleware, and database layers. But seeing it all in one place helps you understand how the pieces fit together.
Here’s everything together in one file you can run:
// complete-example.js
// Run: OAUTH_CLIENT_ID=xxx node complete-example.js
const express = require('express');
const session = require('express-session');
const crypto = require('crypto');
const app = express();
// - - Configuration - -
const CLIENT_ID = process.env.OAUTH_CLIENT_ID;
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET; // Optional
const REDIRECT_URI = 'http://localhost:3000/callback';
const AUTH_URL = 'https://twitter.com/i/oauth2/authorize';
const TOKEN_URL = 'https://api.x.com/2/oauth2/token';
const SCOPES = ['tweet.read', 'tweet.write', 'users.read', 'offline.access'];
// - - Session Setup - -
app.use(session({
secret: 'demo-secret',
resave: false,
saveUninitialized: true,
cookie: { httpOnly: true }
}));
// - - Storage - -
const pkceStore = new Map(); // state → { verifier, userId }
const tokenStore = new Map(); // userId → tokens
// - - PKCE Functions - -
function generateVerifier() {
return crypto.randomBytes(96).toString('base64url');
}
function generateChallenge(verifier) {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
// - - Routes - -
// Home page with login link
app.get('/', (req, res) => {
// Simulate a logged-in user (in real apps, this comes from your auth system)
if (!req.session.userId) {
req.session.userId = 'demo-user-' + Date.now();
}
const token = tokenStore.get(req.session.userId);
if (token) {
res.send(`
<h1>Connected to X!</h1>
<p>User ID: ${req.session.userId}</p>
<p>Access token: ${token.access_token.substring(0, 20)}…</p>
<p>Expires in: ${Math.round((token.expires_at - Date.now()) / 1000 / 60)} minutes</p>
<a href="/tweet">Post a test tweet</a> |
<a href="/logout">Disconnect</a>
`);
} else {
res.send(`
<h1>X OAuth Demo</h1>
<p>User ID: ${req.session.userId}</p>
<a href="/login">Connect with X</a>
`);
}
});
// Start OAuth flow
app.get('/login', (req, res) => {
if (!req.session.userId) {
return res.status(401).send('Session required. Go to <a href="/">home</a> first.');
}
const state = crypto.randomBytes(16).toString('hex');
const verifier = generateVerifier();
const challenge = generateChallenge(verifier);
// CRITICAL: Store verifier with user ID
pkceStore.set(state, {
verifier,
userId: req.session.userId,
createdAt: Date.now()
});
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(' '),
state,
code_challenge: challenge,
code_challenge_method: 'S256'
});
res.redirect(`${AUTH_URL}?${params}`);
});
// Handle callback
app.get('/callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) return res.send(`Error: ${error}`);
const pkceData = pkceStore.get(state);
if (!pkceData) {
return res.status(400).send(
'Invalid state. This often happens if you refreshed the page. ' +
'<a href="/login">Try again</a>'
);
}
// CRITICAL: Verify session matches
if (!req.session.userId || req.session.userId !== pkceData.userId) {
pkceStore.delete(state);
return res.status(401).send('Session mismatch. <a href="/">Start over</a>');
}
pkceStore.delete(state);
// Exchange code for tokens
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
code_verifier: pkceData.verifier
});
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (CLIENT_SECRET) {
headers['Authorization'] = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`;
} else {
params.append('client_id', CLIENT_ID);
}
try {
const response = await fetch(TOKEN_URL, { method: 'POST', headers, body: params.toString() });
const tokens = await response.json();
if (!response.ok) throw new Error(tokens.error_description || tokens.error);
// CRITICAL: Store tokens for THIS user
tokenStore.set(req.session.userId, {
…tokens,
expires_at: Date.now() + tokens.expires_in * 1000
});
console.log(`User ${req.session.userId} connected X account`);
res.redirect('/');
} catch (err) {
res.status(500).send(`Failed: ${err.message}`);
}
});
// Post a tweet
app.get('/tweet', async (req, res) => {
const token = tokenStore.get(req.session.userId);
if (!token) return res.redirect('/');
try {
const response = await fetch('https://api.x.com/2/tweets', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ text: `Hello from OAuth! ${new Date().toISOString()}` })
});
const result = await response.json();
if (!response.ok) {
return res.send(`Tweet failed: ${JSON.stringify(result)}`);
}
res.send(`Tweet posted! ID: ${result.data.id} <br><a href="/">Back</a>`);
} catch (err) {
res.send(`Error: ${err.message}`);
}
});
// Logout
app.get('/logout', (req, res) => {
tokenStore.delete(req.session.userId);
res.redirect('/');
});
app.listen(3000, () => {
console.log('Demo running at http://localhost:3000');
console.log('Requires Node.js 18+ for native fetch');
if (!CLIENT_ID) {
console.error('ERROR: OAUTH_CLIENT_ID environment variable not set!');
}
});
Part 6: Troubleshooting
Common Errors from X API

Common errors from X API table
Part 7: Security Checklist
Before going to production:
- HTTPS everywhere — OAuth callbacks must be HTTPS in production
- Validate state — Always check the state parameter matches
- Store verifiers server-side — Never expose to frontend
- Use S256 method — Never plain
- Link OAuth to user sessions — Verify which user is completing the OAuth flow
- Clean up PKCE store — Prevent memory leaks
- Store tokens encrypted — Don’t save access_tokens in plaintext
- Handle token refresh — Don’t make users re-auth every 2 hours
- Handle revocation — Gracefully prompt re-auth when refresh fails
- Scope minimization — Only request permissions you need
- Use production-ready session store — Redis, database, not in-memory
Part 8: Real-World Implementation Example
The concepts above are implemented in my project called **GitLogs**. If you want to see how all these pieces work together in a multi-user application, here’s where to look:
Repo: Github
Key Implementation Files
src/pkceHelper.js
Production PKCE implementation
src/oauthHandler.js
Complete OAuth flow with token refresh
src/server.js
OAuth routes (lines 691–803) with multi-user support
src/twitterClient.js
Using tokens to make API calls with auto-refresh
src/database.js
Persisting tokens per user in SQLite
Further Reading
- RFC 7636 — PKCE Specification
- X API OAuth 2.0 Docs (you must fucking hate me lmao)
- OAuth 2.0 Security Best Practices
메타데이터
- post_id
- d98c091b2bb4
- slug
- how-to-connect-users-x-twitter-accounts-using-oauth-2-0-pkce-d98c091b2bb4
- url
- https://medium.com/@aayushman2702/how-to-connect-users-x-twitter-accounts-using-oauth-2-0-pkce-d98c091b2bb4
- canonical_url
- https://medium.com/@aayushman2702/how-to-connect-users-x-twitter-accounts-using-oauth-2-0-pkce-d98c091b2bb4
- author_url
- https://medium.com/@aayushman2702
- status
- ok
- fetched_at
- 2026-07-13 14:18:34