Skip to main content

X

This guide explains our X integration in depth: goals, security posture, scopes, OAuth 2.0 PKCE flow, optional OAuth 1.0a media upload, data persisted, token refresh strategy, and how publishing works.

Quick links: FrontendBackend

Goals and constraints

  • Multi-account support: Users can connect multiple X accounts
  • Use OAuth 2.0 Authorization Code with PKCE; keep client secret optional.
  • Persist only minimal profile and token data required for UI and posting.
  • Support text‑only posting via X API v2 /tweets endpoint.
  • Support image/video by uploading media via v1.1 upload.twitter.com/1.1/media/upload.json (OAuth 1.0a) and then creating the post via v2 with media.media_ids.
  • Refresh access tokens automatically when expired if a refresh_token was granted.

Why we do it this way

  • PKCE improves security by avoiding client secret exposure in public clients and mitigating code interception. X supports PKCE with code_challenge and code_verifier.
  • We keep the client secret optional. If configured, we also send Basic auth at token endpoints to satisfy apps that require it; otherwise pure PKCE works.
  • We store only a compact profile for UI (id, display name, username, avatar URL) to reduce PII footprint and keep the schema uniform across providers.
  • We centralize token exchanges and posting in Cloud Functions where we can verify Firebase ID tokens, apply CORS, and keep secrets off the client.
  • We request offline.access to obtain a refresh_token so posts continue to work without frequent re‑auth.

Permissions and scopes

Default scopes requested:

  • tweet.read
  • tweet.write
  • users.read
  • offline.access

Notes:

  • tweet.write is required to create tweets; users.read allows us to fetch the connecting user’s profile for UI.
  • offline.access is needed to receive a refresh_token for long‑lived sessions.

Backend

See also: Frontend

Token and profile flow

1) Start: xAuthStart

  • Requires a Firebase ID token in Authorization: Bearer <idToken>; we verify it to extract uid.
  • Generates a PKCE pair { codeVerifier, codeChallenge, method: 'S256' } and a random state.
  • Persists oauthStates/{state} with { uid, provider: 'x', returnTo, codeVerifier } for CSRF protection and later token exchange.
  • Builds the authorize URL https://x.com/i/oauth2/authorize with:
    • response_type=code
    • client_id
    • redirect_uri
    • scope (space‑delimited)
    • state
    • code_challenge, code_challenge_method=S256
  • Returns { authUrl } or 302 redirects if ?redirect=1.

Configuration keys (from cfg().x):

  • clientId (required)
  • callbackUrl (required)
  • clientSecret (optional; if present, token calls include Basic auth)

2) Callback: xAuthCallback

  • Validates code and state. Reads oauthStates/{state}; rejects if missing.
  • Exchanges code for tokens at POST https://api.x.com/2/oauth2/token with form fields:
    • grant_type=authorization_code, code, redirect_uri, client_id, code_verifier.
    • If clientSecret is configured, we add Authorization: Basic base64(clientId:clientSecret).
  • Response includes access_token, optional refresh_token, expires_in, scope, and token_type.
  • Fetches the connecting user for UI via:
    • GET https://api.x.com/2/users/me?user.fields=profile_image_url,username,name
    • Authorization: Bearer <access_token>
  • Maps to a compact profile card:
    • platformId: data.id
    • displayName: data.name || data.username
    • username: data.username || data.name
    • avatarUrl: data.profile_image_url
    • accountType: 'user'
  • Persists to Firestore at users/{uid}/integrations/x/{platformId} (merge):
    • accessToken, refreshToken (if any), tokenType, scope
    • expiresIn, expiresAt (computed from expires_in), updatedAt
    • profile (display profile for UI)
  • Deletes the oauthStates/{state} doc.
  • Redirects to returnTo?x=connected if provided, or returns a plain success message.

3) Refreshing access tokens

  • On publish, we call an internal helper that checks expiresAt and, if expired and refreshToken exists, POSTs grant_type=refresh_token to the same token endpoint.
  • We update the Firestore doc with the new access_token, possibly new refresh_token, and recomputed expiresAt.

Multi-Account Support

Users can connect multiple X accounts. Each account is stored separately with a unique accountId.

Storage path: users/{uid}/integrations/x/{accountId}

Where accountId is the X user ID (platformId).

Account selection:

  • If user has one account: Automatically selected, no accountId required in API requests
  • If user has multiple accounts: accountId is required in API requests (e.g., /v1/posts)
  • Missing required accountId returns 400 error with code missing_account

Data model

Firestore path: users/{uid}/integrations/x/{accountId}

Fields we persist:

  • accountId: Unique identifier for this X account (same as platformId)
  • Tokens and expiry: accessToken, refreshToken, tokenType, scope, expiresIn, expiresAt, updatedAt
  • UI profile card (profile):
    • platformId, displayName, username, avatarUrl, accountType: 'user'
  • enabled_for_repost: (optional) boolean flag for automatic reposting

We intentionally avoid storing additional private details beyond what we need for UI and posting.

Publishing

Endpoint: Cloud Function publishX

Input:

  • Method: POST
  • Headers: Authorization: Bearer <Firebase ID token>, Content-Type: application/json
  • Body source: orchestrator fan‑out body { userId, postSubmissionId, request } where request.post.content contains:
    • text?: string
    • mediaUrls?: string[] (optional; we fetch and upload the first URL if provided)
    • mediaIds?: string[] (optional; attach directly if provided)

Flow:

  1. Verify Firebase ID token and resolve uid.
  2. Resolve account: Determine accountId from request or auto-select if only one account exists
  3. Load users/{uid}/integrations/x/{accountId}; error if missing.
  4. Ensure a fresh access token via the refresh helper; persist updates if refreshed.
  5. If mediaIds provided, skip upload.
  6. Else if mediaUrls[0] present:
    • If OAuth1 tokens are present, download the URL and upload to v1.1 media endpoint (INIT/APPEND/FINALIZE for video, single upload for image) to obtain a media_id.
    • If no OAuth1 tokens, return 400 with x_media_requires_oauth1_or_mediaIds.
  7. Create the post: POST https://api.x.com/2/tweets with { text, media: { media_ids: [...] } } using the OAuth2 Bearer accessToken.
  8. Return { ok: true, id } on success; otherwise return an error body for client surfacing and log details server‑side.

Current limitations:

  • Polls, reply/threading, alt text, and tagging are not implemented yet.

OAuth 1.0a for media upload (optional)

Why: Per X’s guidance, v2 Create Post cannot upload raw media; you must provide media_ids previously uploaded with v1.1 or via Media Studio. See the integration guide (Adding media to a Post) and Create Post reference.

Flows we support:

  1. Chained connect (one pass): After xAuthCallback (OAuth2), if missing OAuth1 tokens and consumer keys exist, we auto‑redirect to xAuth1Start and then back to the app. Two quick consents, one UX.
  2. Deferred connect: If posting with mediaUrls and no OAuth1 tokens are present, we return a 400 with x_media_requires_oauth1_or_mediaIds. The UI can prompt the user to enable media by completing the OAuth1 connect.

Config keys (Functions env or config): X_CONSUMER_KEY, X_CONSUMER_SECRET, X_OAUTH1_CALLBACK_URL. App settings: enable OAuth 1.0a on the X app and add the exact callback URL for xAuth1Callback.

Observability

  • Errors are surfaced in HTTP responses; detailed bodies are logged server‑side on failures (token exchange, user fetch, or tweet create).
  • Look for xAuthStart, xAuthCallback, and xPublish logs in Cloud Functions.

Security posture

  • All user‑initiated endpoints require a Firebase ID token; tokens are scoped to the calling uid.
  • Secrets (client secret) and PKCE verifier are handled server‑side only.
  • Minimal profile data is stored for UI; no broad user datasets are persisted.

Gotchas and troubleshooting

  • Ensure your X app is configured with the exact callbackUrl used in our config; mismatches will cause token exchange failures.
  • PKCE is required by our flow. If you disable PKCE in your X app settings, the exchange may fail.
  • If posting fails with a permissions error, verify the app has tweet.write access in the X developer portal and that the user granted the scope.
  • If you don’t see refresh_token in the callback, add offline.access to scopes and reconnect.
  • apps/functions/x.js: xAuthStart, xAuthCallback, xPublish
  • apps/functions/index.js: exports X functions
  • apps/web/src/features/integrations/services/authStart.ts: builds the start‑auth request for x
  • apps/web/src/features/integrations/services/publish.ts: publishX
  • apps/web/src/features/integrations/hooks/useConnectIntegration.ts: triggers connect flow from UI
  • apps/web/src/features/integrations/hooks/usePublishX.ts: hook for posting

External references

Frontend

See also: Backend

Hooks and services:

  • apps/web/src/features/integrations/hooks/useConnectIntegration.ts
    • Starts OAuth by calling startAuth('x', idToken, returnTo, isLocal).
    • startAuth uses a platform→URL map (x: https://xauthstart-*.run.app) to build the authorize URL on the server and returns it to the client.
  • apps/web/src/features/integrations/hooks/usePublishX.ts
    • Posts text by calling the Functions endpoint via publishX(functionsBase, idToken, text).
  • apps/web/src/features/integrations/services/publish.ts
    • Implements the network call POST {functionsBase}/xPublish with { text }.

UI touchpoints:

  • apps/web/src/features/integrations/components/PlatformPicker.tsx renders the connected X profile using integrations.x.profile from Firestore.
  • apps/web/src/app/settings/page.tsx is where users connect integrations; it uses the connect hook to initiate OAuth.

Request/response shapes:

  • Connect: no body (server returns { authUrl }) — handled inside the connect hook.
  • Publish: POST xPublish body { text: string }, returns { ok: true, id } on success.

Error handling:

  • The hooks surface HTTP text on errors; e.g. missing text or Not connected are thrown as Error messages for the UI to display.

  • Authorize: https://x.com/i/oauth2/authorize

  • Token: https://api.x.com/2/oauth2/token

  • Create Tweet: https://api.x.com/2/tweets