X / Twitter

X / Twitter OAuth2

Authorization Code + PKCE Exchange for web and mobile — X Developer Portal setup, API reference, and frontend examples.

Overview

X / Twitter login is optional and controlled by TWITTER_AUTH_ENABLED. When disabled, Twitter endpoints return 503 Service Unavailable. JWTs are never placed in the redirect URL; the API issues a one-time exchangeCode (Redis, 60 seconds). The same POST /auth/exchange endpoint is shared with Google and Facebook. X requires PKCE, so the API uses Fastify session middleware (@fastify/session) with a SameSite=Lax cookie (required for the PKCE round-trip).

  • • Feature-flagged via environment
  • • Passport Twitter OAuth2 strategy (@superfaceai) + TwitterAuthGuard
  • • PKCE with Fastify session
  • • Redirect allowlist prevents open redirects
  • • Same token response shape as POST /auth/login

X Developer Portal

Create a Project and App with OAuth 2.0, enable Request email from users, and set the Callback URI to match TWITTER_CALLBACK_URL.

  1. Open developer.x.com → Projects & Apps → create a Project and App
  2. Under User authentication settings, enable OAuth 2.0
  3. Enable “Request email from users” (required for users.email / confirmed_email)
  4. Set Callback URI / Redirect URL (e.g. http://localhost:3000/api/v1/auth/twitter/callback)
  5. Copy Client ID and Client Secret into your .env
  6. Scopes used by this API: tweet.read, users.read, offline.access, users.email

Environment variables

Loaded from .env via src/config/twitter-oauth.config.ts (namespace twitterOAuth on ConfigService). See .env.example. Set SESSION_SECRET (or rely on JWT_SECRET) for PKCE sessions.

Environment.env
TWITTER_AUTH_ENABLED=true
TWITTER_CLIENT_ID=your-twitter-client-id
TWITTER_CLIENT_SECRET=your-twitter-client-secret
TWITTER_CALLBACK_URL=http://localhost:3000/api/v1/auth/twitter/callback
TWITTER_REDIRECT_ALLOWLIST=myapp://success,http://localhost:5173/auth/callback
TWITTER_OAUTH_DEFAULT_ROLES=
# SESSION_SECRET=optional-session-secret

Default roles for new Twitter users

New Twitter users receive roles: [] by default (same as password register). Set TWITTER_OAUTH_DEFAULT_ROLES in .env to a comma-separated list of Role values (e.g. user or user,manager). Existing users linked by email keep their current roles.

Sequence

  1. Client opens GET /api/v1/auth/twitter?redirect=<allowlisted URL>
  2. User consents on X; browser hits GET /api/v1/auth/twitter/callback (PKCE via session)
  3. API upserts user, stores exchangeCode in Redis, redirects to redirect?code=...
  4. Client POSTs { code } to /api/v1/auth/exchange and receives JWTs

API reference

1. Start OAuth

Opens X consent. Query redirect must match a prefix in TWITTER_REDIRECT_ALLOWLIST (or omit to use the first allowlist entry).

HTTPGET /api/v1/auth/twitter
GET /api/v1/auth/twitter?redirect=http%3A%2F%2Flocalhost%3A5173%2Fauth%2Fcallback

400 invalid redirect · 503 feature disabled

2. Callback

Handled by the API. Redirects to your app with ?code= (or &code= if the URL already has a query).

HTTPGET /api/v1/auth/twitter/callback
GET /api/v1/auth/twitter/callback?code=TWITTER_AUTH_CODE&state=...
→ 302 Location: http://localhost:5173/auth/callback?code=EXCHANGE_CODE

3. Exchange code

Public endpoint shared with Google and Facebook. Consumes the code (single use) and returns the same shape as login.

HTTP RequestPOST /api/v1/auth/exchange
POST /api/v1/auth/exchange
Content-Type: application/json

{
  "code": "EXCHANGE_CODE"
}
HTTP Response200 OK
{
  "user": {
    "id": "...",
    "email": "[email protected]",
    "firstName": "Jane",
    "lastName": "Doe",
    "roles": [],
    "twitterId": "2244...",
    "isActive": true
  },
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}

401 invalid/expired/used code · 503 social auth disabled

Frontend — Web (SPA)

Typical flow: button starts OAuth in the same window (or popup); a callback route on your frontend origin reads code from the query string and calls exchange.

JavaScriptStart X / Twitter login
const API = 'http://localhost:3000/api/v1';
const redirect = encodeURIComponent(
  'http://localhost:5173/auth/callback'
);

// Full-page redirect to Nest → X → back to SPA
window.location.href = `${API}/auth/twitter?redirect=${redirect}`;
JavaScriptCallback page /auth/callback
// /auth/callback on the SPA
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (!code) throw new Error('Missing exchange code');

const res = await fetch('http://localhost:3000/api/v1/auth/exchange', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ code }),
});
if (!res.ok) throw new Error(await res.text());

const { user, accessToken, refreshToken } = await res.json();
// persist tokens as your app requires
sessionStorage.setItem('accessToken', accessToken);
console.log('signed in', user.email);
  • • Add your frontend callback URL to TWITTER_REDIRECT_ALLOWLIST (e.g. http://localhost:5173/auth/callback)
  • • TWITTER_CALLBACK_URL must point at the Nest API, not the SPA
  • • Store accessToken / refreshToken securely (memory + httpOnly cookie patterns as you prefer)

Frontend — Mobile (deep link)

Use a custom scheme (or universal link) in the allowlist. After X, the API redirects to myapp://success?code=.... The app opens, parses the code, and calls exchange.

React NativeOpen OAuth in browser
import * as Linking from 'expo-linking';
import * as WebBrowser from 'expo-web-browser';

const API = 'https://api.example.com/api/v1';
const redirect = encodeURIComponent('myapp://success');

async function signInWithTwitter() {
  const url = `${API}/auth/twitter?redirect=${redirect}`;
  await WebBrowser.openAuthSessionAsync(url, 'myapp://success');
}
React NativeHandle deep link + exchange
import * as Linking from 'expo-linking';

async function exchangeFromUrl(url) {
  const { queryParams } = Linking.parse(url);
  const code = queryParams?.code;
  if (!code) return;

  const res = await fetch(`${API}/auth/exchange`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code }),
  });
  const data = await res.json();
  // data.accessToken, data.refreshToken, data.user
  return data;
}

Linking.addEventListener('url', ({ url }) => {
  exchangeFromUrl(url);
});
  • • Register the scheme in the OS / Expo app.json
  • • Prefer Linking.addEventListener('url', ...) and getInitialURL()
  • • Exchange over HTTPS against your API base URL

Security notes

  • • Redirect URLs must match TWITTER_REDIRECT_ALLOWLIST prefixes
  • • Exchange codes are single-use and expire in 60 seconds (Redis)
  • • PKCE code_verifier is stored in the Fastify session cookie (SameSite=Lax)
  • • Existing local users are linked by verified Twitter email (twitterId set; roles unchanged)
  • • Twitter-only users have password null — password login / change-password will fail until a password is set
  • • users.email + confirmed_email are required; login fails if X does not return an email

User model

Postgres entity / interface fields used by X / Twitter login:

  • • twitterId — unique nullable X / Twitter subject ID
  • • password — nullable for OAuth-only accounts
  • • isActive — set true for new Twitter users
  • • roles — from TWITTER_OAUTH_DEFAULT_ROLES env (default [])

Related