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: Frontend • Backend
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
/tweetsendpoint. - 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 withmedia.media_ids. - Refresh access tokens automatically when expired if a
refresh_tokenwas 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_challengeandcode_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
profilefor 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.accessto obtain arefresh_tokenso posts continue to work without frequent re‑auth.
Permissions and scopes
Default scopes requested:
tweet.readtweet.writeusers.readoffline.access
Notes:
tweet.writeis required to create tweets;users.readallows us to fetch the connecting user’s profile for UI.offline.accessis needed to receive arefresh_tokenfor 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 extractuid. - Generates a PKCE pair
{ codeVerifier, codeChallenge, method: 'S256' }and a randomstate. - 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/authorizewith:response_type=codeclient_idredirect_uriscope(space‑delimited)statecode_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
codeandstate. ReadsoauthStates/{state}; rejects if missing. - Exchanges
codefor tokens atPOST https://api.x.com/2/oauth2/tokenwith form fields:grant_type=authorization_code,code,redirect_uri,client_id,code_verifier.- If
clientSecretis configured, we addAuthorization: Basic base64(clientId:clientSecret).
- Response includes
access_token, optionalrefresh_token,expires_in,scope, andtoken_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.iddisplayName:data.name || data.usernameusername:data.username || data.nameavatarUrl:data.profile_image_urlaccountType:'user'
- Persists to Firestore at
users/{uid}/integrations/x/{platformId}(merge):accessToken,refreshToken(if any),tokenType,scopeexpiresIn,expiresAt(computed fromexpires_in),updatedAtprofile(display profile for UI)
- Deletes the
oauthStates/{state}doc. - Redirects to
returnTo?x=connectedif provided, or returns a plain success message.
3) Refreshing access tokens
- On publish, we call an internal helper that checks
expiresAtand, if expired andrefreshTokenexists, POSTsgrant_type=refresh_tokento the same token endpoint. - We update the Firestore doc with the new
access_token, possibly newrefresh_token, and recomputedexpiresAt.
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
accountIdrequired in API requests - If user has multiple accounts:
accountIdis required in API requests (e.g.,/v1/posts) - Missing required
accountIdreturns400error with codemissing_account
Data model
Firestore path: users/{uid}/integrations/x/{accountId}
Fields we persist:
accountId: Unique identifier for this X account (same asplatformId)- 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 }whererequest.post.contentcontains:text?: stringmediaUrls?: string[](optional; we fetch and upload the first URL if provided)mediaIds?: string[](optional; attach directly if provided)
Flow:
- Verify Firebase ID token and resolve
uid. - Resolve account: Determine
accountIdfrom request or auto-select if only one account exists - Load
users/{uid}/integrations/x/{accountId}; error if missing. - Ensure a fresh access token via the refresh helper; persist updates if refreshed.
- If mediaIds provided, skip upload.
- 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.
- 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
- Create the post:
POST https://api.x.com/2/tweetswith{ text, media: { media_ids: [...] } }using the OAuth2 BeareraccessToken. - 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.
- Integration guide: Adding media to a Post Integration guide
- Create Post reference Create Post
Flows we support:
- Chained connect (one pass): After
xAuthCallback(OAuth2), if missing OAuth1 tokens and consumer keys exist, we auto‑redirect toxAuth1Startand then back to the app. Two quick consents, one UX. - Deferred connect: If posting with
mediaUrlsand no OAuth1 tokens are present, we return a 400 withx_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, andxPublishlogs 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
callbackUrlused 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.writeaccess in the X developer portal and that the user granted the scope. - If you don’t see
refresh_tokenin the callback, addoffline.accessto scopes and reconnect.
Related code
apps/functions/x.js:xAuthStart,xAuthCallback,xPublishapps/functions/index.js: exports X functionsapps/web/src/features/integrations/services/authStart.ts: builds the start‑auth request forxapps/web/src/features/integrations/services/publish.ts:publishXapps/web/src/features/integrations/hooks/useConnectIntegration.ts: triggers connect flow from UIapps/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). startAuthuses a platform→URL map (x: https://xauthstart-*.run.app) to build the authorize URL on the server and returns it to the client.
- Starts OAuth by calling
apps/web/src/features/integrations/hooks/usePublishX.ts- Posts text by calling the Functions endpoint via
publishX(functionsBase, idToken, text).
- Posts text by calling the Functions endpoint via
apps/web/src/features/integrations/services/publish.ts- Implements the network call
POST {functionsBase}/xPublishwith{ text }.
- Implements the network call
UI touchpoints:
apps/web/src/features/integrations/components/PlatformPicker.tsxrenders the connected X profile usingintegrations.x.profilefrom Firestore.apps/web/src/app/settings/page.tsxis 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 xPublishbody{ text: string }, returns{ ok: true, id }on success.
Error handling:
-
The hooks surface HTTP text on errors; e.g. missing
textorNot connectedare thrown asErrormessages 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