Skip to main content

YouTube

This guide documents our YouTube integration: the goals, the OAuth flow we implement in Cloud Functions, which scopes we request, how we fetch and persist channel information, and how we keep tokens fresh. It’s structured for audits, troubleshooting, and future feature work (uploads).

Quick links: FrontendBackend

Goals and constraints

  • Multi-account support: Users can connect multiple YouTube Channels
  • Persist only what we need for UI and future uploads: access/refresh tokens, minimal channel profile, and basic metadata.
  • Enforce required scopes server‑side so channel info is always retrievable and uploads are permitted.

Why we do it this way

  • YouTube Data API returns channel details for the authenticated user only when the token has a YouTube read scope. We enforce the scopes at the server to avoid partial grants.
  • We store a compact profile for UI (platformId, displayName, username, avatarUrl, accountType: 'channel') and keep the raw channel object under extra.channel for observability without widening our primary schema.
  • Long‑lived stability: we store the refreshToken and provide both an on‑demand refresh endpoint and a scheduled sweep to rotate access tokens before expiry.

Backend

See also: Frontend

Permissions and scopes

Server‑enforced default scope set (merged with any incoming scopes):

  • https://www.googleapis.com/auth/youtube (broad read/write access to YouTube account)
  • https://www.googleapis.com/auth/youtube.readonly (read channel info)
  • https://www.googleapis.com/auth/youtube.upload (upload videos)
  • https://www.googleapis.com/auth/youtube.force-ssl (some endpoints prefer this variant)

Notes:

  • We accept optional client‑provided scopes, but we always merge and de‑duplicate with the required set above so essential fields are available.
  • The OAuth request uses access_type=offline, include_granted_scopes=true, and prompt=consent select_account to force a fresh consent when needed and obtain a refresh token.

Token and profile flow

1) Start: ytAuthStart

  • Requires a Firebase ID token in Authorization: Bearer <idToken>; we verify it and extract uid.
  • Creates a state document at oauthStates/{state} with { uid, provider: 'youtube', returnTo }.
  • Builds the authorize URL https://accounts.google.com/o/oauth2/v2/auth with:
    • client_id, redirect_uri, response_type=code
    • scope (merged set described above)
    • access_type=offline, include_granted_scopes=true, prompt=consent select_account

2) Callback: ytAuthCallback

  • Validates state by reading oauthStates/{state}; rejects if missing.
  • Exchanges code for tokens via POST https://oauth2.googleapis.com/token with client_id, client_secret, and exact redirect_uri.
  • Computes expiresIn and expiresAt from the token response.
  • Fetches channel profile for UI via YouTube Data API:
    • GET https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true
    • Authorization: Bearer <access_token>
  • Maps the first channel to a compact display profile:
    • platformId: channel id
    • displayName: snippet.title
    • username: snippet.customUrl (if present)
    • avatarUrl: from snippet.thumbnails (prefers default or high)
    • accountType: 'channel'
  • Persists to Firestore at users/{uid}/integrations/youtube (merge):
    • accessToken, refreshToken, tokenType, scope, expiresIn, expiresAt, updatedAt
    • profile (display profile for UI)
    • channelId (same as profile.platformId) and accountName (same as profile.displayName)
    • extra.channel (raw channel object)
  • Deletes the oauthStates/{state} document.
  • Redirects back to returnTo with ?youtube=connected if provided, or returns a plain success page.

3) Refreshing access tokens

  • ytRefreshNow (HTTP): Verifies the caller’s Firebase ID token, then refreshes this user’s YouTube tokens by calling the internal helper refreshYouTubeForUid(uid).
  • ytRefreshSweep (scheduled every 12h):
    • Scans users/*/integrations/youtube.
    • If expiresAt is within 6 hours (or missing) and a refreshToken exists, calls the refresh helper.
    • Writes new accessToken, optionally new refreshToken, carries forward scope, and updates expiresIn/At.

Multi-Account Support

Users can connect multiple YouTube Channels. Each channel is stored separately with a unique accountId.

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

Where accountId is the YouTube Channel ID.

Account selection:

  • If user has one channel: Automatically selected, no accountId required in API requests
  • If user has multiple channels: 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/youtube/{accountId}

Fields we persist:

  • accountId: Unique identifier for this YouTube channel (same as channelId)
  • Tokens and expiry: accessToken, refreshToken, tokenType, scope, expiresIn, expiresAt, updatedAt
  • UI profile card (profile):
    • platformId, displayName, username, avatarUrl, accountType: 'channel'
  • Convenience: channelId, accountName
  • Debug payload (extra.channel): raw object from channels.list
  • enabled_for_repost: (optional) boolean flag for automatic reposting

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

Uploads (prerequisites and plan)

We already request youtube.upload to enable future uploads. When implemented, the flow will use videos.insert with the stored access token and will follow a resumable upload process for large videos. Until then, the integration focuses on connection and channel identity for UI.

References:

  • Videos insert: https://developers.google.com/youtube/v3/docs/videos/insert

Observability

  • Critical failures return HTTP errors to the client; additional details are logged on the server (only minimal, non‑sensitive snippets on errors).
  • On channels.list failure we capture status and a truncated body to aid scope/permission troubleshooting.

Security posture

  • We require a Firebase ID token for all user‑initiated endpoints and store tokens under the user’s document.
  • We only persist the minimum necessary profile fields for UI; full provider payloads go under extra.channel for debugging.
  • We follow our engineering guardrails on secret handling and server‑side token exchange.

Gotchas and troubleshooting

  • A 403 from channels.list with ACCESS_TOKEN_SCOPE_INSUFFICIENT means the access token lacks youtube or youtube.readonly. Revoke the grant in Google Account settings and reconnect.
  • Ensure the OAuth consent screen lists the YouTube scopes and that the YouTube Data API v3 is enabled for the same project as the OAuth client.
  • If profile fields are missing, verify the channel has a customUrl and that thumbnails are present; the code safely falls back to available sizes.

Frontend

See also: Backend

Hooks and services:

  • apps/web/src/features/integrations/hooks/useConnectIntegration.tsstartAuth('youtube', ...).
  • apps/web/src/features/integrations/components/PlatformPicker.tsx renders integrations.youtube.profile.

Note: Upload publishing hook is not yet implemented.

  • apps/functions/youtube.js: ytAuthStart, ytAuthCallback, ytRefreshNow, ytRefreshSweep
  • apps/web/src/features/integrations/components/PlatformPicker.tsx: renders the integration card using integrations.youtube.profile

External references

  • YouTube Data API: https://developers.google.com/youtube/v3
  • Channels list: https://developers.google.com/youtube/v3/docs/channels/list