Build With X

X API OAuth

OAuth 2.0 PKCE authentication for user-context operations

OAuth 2.0 PKCE Flow

X API requires OAuth 2.0 with PKCE for user-context operations (posting tweets, DMs, etc.).

1. Generate PKCE Challenge

import crypto from "crypto"

function generatePKCE() {
  // Generate code verifier (43-128 characters)
  const codeVerifier = crypto.randomBytes(32).toString("base64url")

  // Generate code challenge (SHA256 hash of verifier)
  const codeChallenge = crypto
    .createHash("sha256")
    .update(codeVerifier)
    .digest("base64url")

  return { codeVerifier, codeChallenge }
}

const { codeVerifier, codeChallenge } = generatePKCE()
// Store codeVerifier in session for later use

2. Authorization URL

function getAuthorizationUrl(
  clientId: string,
  redirectUri: string,
  codeChallenge: string,
  state: string
) {
  const params = new URLSearchParams({
    response_type: "code",
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: "tweet.read tweet.write users.read offline.access",
    state,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  })

  return `https://twitter.com/i/oauth2/authorize?${params}`
}

// Redirect user to this URL
const authUrl = getAuthorizationUrl(
  process.env.X_CLIENT_ID!,
  "http://localhost:3000/callback",
  codeChallenge,
  "random-state-string"
)

3. Handle Callback

// User redirected back with ?code=...&state=...
async function handleCallback(
  code: string,
  codeVerifier: string,
  redirectUri: string
) {
  const response = await fetch("https://api.twitter.com/2/oauth2/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      code,
      grant_type: "authorization_code",
      client_id: process.env.X_CLIENT_ID!,
      redirect_uri: redirectUri,
      code_verifier: codeVerifier,
    }),
  })

  return response.json()
}

// Response contains:
// {
//   token_type: "bearer",
//   access_token: "...",
//   refresh_token: "...",
//   expires_in: 7200,
//   scope: "tweet.read tweet.write users.read offline.access"
// }

4. Refresh Token

async function refreshAccessToken(refreshToken: string) {
  const response = 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: refreshToken,
      client_id: process.env.X_CLIENT_ID!,
    }),
  })

  return response.json()
}

Available Scopes

ScopeDescription
tweet.readRead tweets
tweet.writePost and delete tweets
tweet.moderate.writeHide/unhide replies
users.readRead user info
follows.readRead following/followers
follows.writeFollow/unfollow users
offline.accessGet refresh token
space.readRead Spaces
mute.readRead muted accounts
mute.writeMute/unmute accounts
like.readRead liked tweets
like.writeLike/unlike tweets
list.readRead lists
list.writeCreate/manage lists
block.readRead blocked accounts
block.writeBlock/unblock accounts
bookmark.readRead bookmarks
bookmark.writeAdd/remove bookmarks
dm.readRead direct messages
dm.writeSend direct messages

Complete Example

import crypto from "crypto"
import { serve } from "bun"

// In-memory session store (use Redis in production)
const sessions = new Map<string, { codeVerifier: string; state: string }>()

const CLIENT_ID = process.env.X_CLIENT_ID!
const REDIRECT_URI = "http://localhost:3000/callback"

serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url)

    if (url.pathname === "/login") {
      const { codeVerifier, codeChallenge } = generatePKCE()
      const state = crypto.randomBytes(16).toString("hex")

      sessions.set(state, { codeVerifier, state })

      const authUrl = getAuthorizationUrl(
        CLIENT_ID,
        REDIRECT_URI,
        codeChallenge,
        state
      )

      return Response.redirect(authUrl)
    }

    if (url.pathname === "/callback") {
      const code = url.searchParams.get("code")
      const state = url.searchParams.get("state")

      if (!code || !state) {
        return new Response("Missing code or state", { status: 400 })
      }

      const session = sessions.get(state)
      if (!session) {
        return new Response("Invalid state", { status: 400 })
      }

      const tokens = await handleCallback(
        code,
        session.codeVerifier,
        REDIRECT_URI
      )

      sessions.delete(state)

      return Response.json(tokens)
    }

    return new Response("Not found", { status: 404 })
  },
})

Token Storage

Store tokens securely. Use encrypted storage in production; never expose tokens to client-side code.

interface TokenData {
  accessToken: string
  refreshToken: string
  expiresAt: number
}

class TokenManager {
  private tokens: Map<string, TokenData> = new Map()

  async getValidToken(userId: string): Promise<string> {
    const data = this.tokens.get(userId)
    if (!data) throw new Error("No token for user")

    // Refresh if expiring within 5 minutes
    if (Date.now() > data.expiresAt - 5 * 60 * 1000) {
      const newTokens = await refreshAccessToken(data.refreshToken)
      this.tokens.set(userId, {
        accessToken: newTokens.access_token,
        refreshToken: newTokens.refresh_token,
        expiresAt: Date.now() + newTokens.expires_in * 1000,
      })
      return newTokens.access_token
    }

    return data.accessToken
  }
}

On this page