Update 2026-05-20: This tutorial is now proposed as an official Apify Academy doc - apify/apify-docs#2549. Track the review there or open a Discussion on the reference repo if you want to compare notes on the pattern.
How to set up refresh-token-only OAuth for a multi-tenant Apify Actor (Gmail, 10 minutes)
If you're shipping an Apify Actor that calls a per-user Google API (Gmail, Calendar, Drive) and you want the simplest auth model that works for cold strangers, this is the setup.
The pattern: the buyer pastes three strings — refresh_token, client_id, client_secret — into the Actor input. The Actor exchanges them for a short-lived access token at runtime, hits the API, exits. No mailbox cache, no per-user OAuth callback URL, no Apify-side identity storage.
You can finish this in 10 minutes including the Google Cloud setup. I'll show the working version end-to-end. This is the actual pattern I'm running in production on apify.com/foxck/gmail-inbox-intel.
Step 1 — Google Cloud setup (5 min)
- Open console.cloud.google.com, create a new project or pick an existing one.
- APIs & Services → Enable APIs → enable Gmail API (or whichever Google API you need).
-
APIs & Services → OAuth consent screen → External, fill in app name + your email, add the scope you actually need (e.g.
gmail.readonly). If you're staying under 100 users, leave the app in "Testing" mode and add test users manually — no Google verification required. -
APIs & Services → Credentials → Create credentials → OAuth client ID → Desktop app. Download the JSON. You now have
client_idandclient_secret.
The "Desktop app" type is the unlock here. It does not require you to host an OAuth redirect URL. Google's library will spin up localhost:8080 during the consent flow and capture the code automatically.
Step 2 — Generate the refresh token (2 min)
The buyer runs this once on their own machine. You don't host this, they do.
# scripts/oauth_setup.py
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
creds = flow.run_local_server(port=8080, access_type="offline", prompt="consent")
print("refresh_token:", creds.refresh_token)
access_type="offline" + prompt="consent" is what guarantees Google returns a refresh token. Without prompt="consent", if the user has authorized this client before, Google will return only an access token. With it, the consent screen reappears and a fresh refresh token gets minted.
Output: one long string starting 1//. That's the refresh_token. They paste it into the Actor input alongside client_id and client_secret.
Step 3 — Exchange for an access token at runtime (1 min)
In the Actor's main handler, before hitting Gmail:
import requests
def get_access_token(client_id, client_secret, refresh_token):
resp = requests.post(
"https://oauth2.googleapis.com/token",
data={
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
},
timeout=10,
)
resp.raise_for_status()
return resp.json()["access_token"]
That access token is good for ~1 hour. For an Actor that runs in seconds-to-minutes, one exchange per run is enough — no caching needed.
Step 4 — Define the Actor input schema (1 min)
INPUT_SCHEMA.json:
{
"title": "Gmail Actor input",
"type": "object",
"required": ["client_id", "client_secret", "refresh_token"],
"properties": {
"client_id": { "type": "string", "title": "Google client_id", "editor": "textfield" },
"client_secret": { "type": "string", "title": "Google client_secret", "editor": "textfield", "isSecret": true },
"refresh_token": { "type": "string", "title": "Google refresh_token", "editor": "textfield", "isSecret": true }
}
}
The isSecret: true flag tells the Apify UI to mask the field at rest in the Actor run record. Apify auto-encrypts secret input fields with platform-level keys.
Step 5 — Bonus: a dry-run mode so buyers can test without OAuth (1 min)
The biggest setup-killer for a cold buyer is the Google Cloud Console + OAuth client + consent screen detour in step 1. They land on your Actor, click Run, hit the OAuth wall, give up.
Cure: an optional dry_run boolean. When true, the Actor skips the OAuth exchange entirely and emits a synthetic dataset matching your real schema:
async def main():
inp = await Actor.get_input()
if inp.get("dry_run"):
await Actor.push_data(SYNTHETIC_SAMPLE)
return
# ... normal flow with OAuth ...
Now the buyer's first interaction is "Click Run on the public example → see what the output looks like." No Cloud Console. They commit to the OAuth flow only after they've seen the JSON shape and decided they want it.
Why this pattern, not the alternatives
- Apify Integrations OAuth: tying the Actor to Apify's identity store would lock self-host buyers out of the same source.
- Service Account + domain-wide delegation: works for one Google Workspace, not multi-tenant strangers.
- Per-user OAuth callback URL hosted somewhere: extra infra, extra cost, extra failure surface.
Refresh-token-only OAuth shifts the trust boundary cleanly: the buyer holds their own credentials, the Actor is stateless, both Apify and self-host modes work from the same code.
The repo
Full source running this pattern in production: github.com/foxck016077/apify-gmail-inbox-intel (MIT). The Actor lives at apify.com/foxck/gmail-inbox-intel — click "Try for free" with the dry_run: true input and you'll see the output shape without doing the OAuth setup.
If you're building something similar and the pattern's unclear, the AMA discussion is open.
Found this useful? My deep-dive on reverse-engineering Claude Code: Claude Code Mastery — The Reverse-Engineering Guide.
Sample report preview: Friday Triage gist — anonymized 10-thread example of the $99 Done-For-You triage output. Grounded in r/sales 1tdngew (49 comments on re-engaging cold prospects) and r/smallbusiness 1td0827 (60-comment thread, top reply at 61 score: "holding 50 open loops in your head").
More from the shop:
- Claude Code Mastery: The Reverse-Engineering Guide — $49, 19 pages, every env var / hook event / settings key extracted from the v2.1.90 binary
- 5 n8n Workflows that Save 10+ Hours/Week — $29, the bundle
- AI Lead Auto-Responder — $39, Gmail → instant AI-classified reply
Read the latest checkpoint: Day 16 — +51 reader spike in 85 min, 0 sales
Day 18 — pbot v1 dev preview shipped
After 18 days of this ZERO-TEN cold start: $9 PDF killed at Day 17, pivoted to pbot — a one-click personal knowledge bot you install on your own machine. Talk to it from LINE / Telegram / Zalo on your phone.
v1 dev preview is real: 93 MB macOS .dmg packaged, 15k-chunk SQLite FTS5 queries in 0-3 ms, Anthropic real calls with source citations, daemon auto-start on boot. Day 18 deep dive: the 7-line bigram fix for Chinese search.
Top comments (0)