← Back to list

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.

Aayushman Singh · 2026-01-08 12:05 · 45 claps · 14.2 min read
#oauth2 #xapi #pkce #software-development
Open on Medium ↗
Wiki topics: 🔧 · Data Engineering

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:

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:

  1. Insecure — You’d have access to their entire account
  2. Fragile — If they change their password, your app breaks
  3. 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

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

Explicit PKCE flow within OAuth

PKCE (pronounced “pixie”) adds a secret handshake:

  1. Before Step 2: Your app generates a random secret called a code_verifier
  2. In Step 2: Your app sends a hashed version (code_challenge) to X
  3. In Step 5: Your app sends the original code_verifier to X
  4. 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:

  1. Starting the flow: Building the URL where users authorize your app
  2. 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:

  1. GET /auth/x — Start the OAuth flow
  2. 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:

  1. When starting OAuth: Store the user’s ID with the PKCE verifier
  2. In the callback: Check the session to ensure it’s the same user
  3. 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

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


메타데이터
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