Google OAuth & keys in Campaign Manager

Every moving part, and the full round trip of the keys — from the button the user clicks to the encrypted refresh token that mints an access token on every nightly sync. Written as a 101 explainer, but every name, route and file below is the real code.

01The cast

OAuth exists to answer one question: how does Campaign Manager read a user's Google Ads data without ever knowing their Google password? Five parties are involved, and each holds a different secret.

The user Owns the Google Ads account Campaign Manager Rails app · the "client" my-ad-agent.com Google Accounts Consent screen + token endpoint Google Ads API The resource server googleads.googleapis.com Postgres ad_accounts row, tokens encrypted at rest Dotted = tokens stored / re-read. Solid = a live HTTP request.

02The three secrets — and who owns which

This is the part people usually get tangled in. Google Ads is unusual: it needs three distinct credentials, and they come from three different places with three different lifetimes.

SecretWhose is itWhere it livesLifetime
Client ID + secret
app-level
Campaign Manager's. One pair for the whole product — identical for every customer. Rails credentials google_ads.client_id/.client_secret, or GOOGLE_ADS_CLIENT_ID / _SECRET env. Forever, until rotated in Google Cloud Console.
Developer token
app-level
Campaign Manager's. Google Ads-specific: it says "this software is approved to use the Ads API at all." google_ads.developer_token / GOOGLE_ADS_DEVELOPER_TOKEN. Sent as the developer-token header. Forever. Not an OAuth thing — an API access thing.
Refresh token
per-user
The customer's. Minted once when they click Allow. Represents their consent, for their account. Encrypted column ad_accounts.refresh_token. Until revoked — or ~7 days if the consent screen is in Testing mode.
Access token
derived
Short-lived derivative of the refresh token. Never the source of truth. Column exists, but the live path re-mints one per call. ~1 hour.

The mental model: client ID + secret identify the software. The developer token proves the software is allowed near the Ads API. The refresh token identifies the person and the permission they granted. All three must be present together for a single API call to succeed — that's exactly what build_client asserts before it does anything.

03The round trip, step by step

This is the whole dance. Steps 1–7 happen once, in a browser, in about ten seconds. Step 8 onward repeats forever, with no user present.

User's browser Campaign Manager Google 1 · Clicks "Connect Google Ads" → GET /dashboard/auth/google_ads 2 · Mints state = SecureRandom.hex(32) stores it in the session (CSRF nonce) 3 · 302 redirect to accounts.google.com/o/oauth2/v2/auth client_id · redirect_uri · scope=.../auth/adwords · access_type=offline · prompt=consent · state 4 · Browser lands on Google's consent screen. User signs in and clicks Allow. The password is typed into Google, never into Campaign Manager. That is the whole point. 5 · Redirect back to /dashboard/auth/google_ads/callback?code=…&state=… …which the browser follows straight into the app 6 · valid_state? — secure_compare vs session. Mismatch → abort. This blocks CSRF / code injection. 7 · POST oauth2.googleapis.com/token — the back-channel exchange code + client_id + client_secret + redirect_uri + grant_type=authorization_code ← access_token · refresh_token · expires_in Server-to-server. The secret and the tokens never touch the browser. 8 · listAccessibleCustomers — "which Ads accounts can this login reach?" Bearer access_token + developer-token header. 0 → error · 1 → auto-pick · many → user chooses 9 · upsert_ad_account refresh_token + access_token → ad_accounts (encrypted), auth_method: "oauth2", session wiped, backfill enqueued — browser session ends · everything below runs unattended, forever — 10 · Nightly job: POST /token with grant_type=refresh_token ← a fresh 1-hour access_token → query the Ads API → store snapshots
User / browser Campaign Manager Google Token traffic

04Why a code, and not just a token?

The front channel is public

Steps 3–5 travel through the user's browser as URLs. Anything in a URL can leak — browser history, referrer headers, server logs, a screenshot. So Google puts a single-use authorization code there instead of a token. A code is useless on its own.

The back channel is private

Step 7 is a direct server-to-server POST. To redeem the code you must also present the client secret, which only Campaign Manager's server has. So even a stolen code can't be cashed in by an attacker.

That split — public code, private redemption — is the "authorization code flow". The state nonce closes the remaining hole: it proves the callback you're handling belongs to a flow this same browser session actually started.

Two parameters that do a lot of quiet work

05Where the keys live afterwards

After step 9 the round trip is complete but not over — it now repeats in miniature on every API call, with three secrets re-converging from two different sources.

App-level config client_id client_secret developer_token Rails credentials or ENV — same for all customers Per-account row refresh_token 🔒 platform_account_id ad_accounts · Active Record encryption GoogleAdsService build_client asserts all four present, or raises verify_refresh_token! Plain HTTPS POST /token first — fails fast & loud if the grant is dead → CredentialsError · "Reconnect this account" Google Ads API (gRPC) Gem refreshes the token internally, runs the GAQL query, returns rows → PerformanceSnapshots in our own DB

Why that pre-flight check exists. The google-ads gem refreshes tokens inside its gRPC calls, where an auth failure looks transient and gets retried to the RPC deadline. A dead refresh token therefore surfaced as a four-hour hang ending in DEADLINE_EXCEEDED. Minting a token over plain HTTP first turns that into a fast, readable "reconnect this account."

06The other path: manual API credentials

There are two ways an account gets connected, and the row remembers which via auth_method.

oauth2

The flow above. Stores a refresh token; explicitly nils developer_token, oauth_client_id, oauth_client_secret — those come from app config, not the row.

api_credentials

A power user pastes their own client ID, secret, developer token and refresh token — typically because they have their own Google Ads MCC and developer token. The row carries all four, and they take precedence over app config in build_client.

That difference is the fingerprint google_ads_connected? uses to tell a genuinely-connected account from a demo/seed row, so live-only features stay hidden when there's nothing real behind them.

07Scope, and the read/write ceiling

Campaign Manager requests exactly one Google scope: https://www.googleapis.com/auth/adwords. Google Ads has no separate read-only scope — that one grant technically covers writes too, so the brakes are applied on our side, not Google's:

08The failure modes, and what each one means

SymptomWhat actually happenedFix
Tokens die every ~7 daysThe OAuth consent screen is still in Testing mode. Google expires refresh tokens for test users after a week — nothing in the code can prevent it.Publish the consent screen to Production in Google Cloud Console. Console-only; no code change helps.
"did not return an access token"The code exchange failed — usually a redirect_uri that doesn't byte-match a registered redirect URI, or a rotated client secret.Match the callback URL exactly in the Cloud Console credential.
No refresh token after reconnectConsent was silently reused. Without prompt=consent Google returns only an access token on a repeat grant.Already handled — the flow always sends prompt=consent.
"No accounts are accessible"listAccessibleCustomers returned empty or threw. The Google login is real but reaches no Ads customer, or the developer token is missing/unapproved.Sign in with a login that has Ads access; check developer-token approval level.
OAuth state mismatchSession was lost between initiate and callback (cookie dropped, different browser, stale tab) — or a genuine CSRF attempt.Start the connect again from the Platforms page in one browser.
Long hang, then DEADLINE_EXCEEDEDA revoked or cross-client refresh token retried inside gRPC. Now pre-empted by verify_refresh_token!.Reconnect the account.

09The map, for when you're in the code

FileDoes
config/routes.rb:80–82The three routes: auth/:platform, .../callback, .../select.
app/controllers/dashboard/oauth_controller.rbPlatform-agnostic conductor: state nonce, state validation, code exchange, account selection, upsert, session cleanup.
app/services/oauth/base_strategy.rbThe four-method contract every platform implements: authorize_url, exchange_code, list_accessible_accounts, build_account_attributes.
app/services/oauth/google_ads_strategy.rbGoogle's specifics: scope, offline access, the token endpoint, listAccessibleCustomers, and naming the customer via GAQL.
app/services/oauth/strategy_factory.rbMaps a platform key to its strategy. Adding a platform = one class + one registry line.
app/models/ad_account.rbEncrypts all four secrets, exposes token_expired?, google_ads_connected?, connection_health; enqueues the initial backfill.
app/services/google_ads_service.rbConsumer side: build_client reassembles the credentials, verify_refresh_token! pre-flights them, GAQL queries run, writes_enabled? gates mutations.

The same shape covers Meta, TikTok, LinkedIn, Microsoft, Pinterest, Snapchat, Shopify and Triple Whale — only the strategy differs. Google is the awkward one purely because of the extra developer token.

10The one-paragraph version

The user clicks Connect. We send them to Google with our public client ID, the scope we want, and a random state we remember. They sign in to Google and click Allow. Google bounces them back with a one-time code. Our server swaps that code for a refresh token in a private call that proves who we are with the client secret. We encrypt the refresh token against their ad account and forget everything else. From then on, every nightly sync trades that refresh token for a one-hour access token, attaches the developer token, and pulls yesterday's numbers — no user, no password, and revocable at any time from the user's Google account.

Built from the Campaign Manager source — routes, strategies, controller and services as of September 2026.