DEV Community

foxck016077
foxck016077

Posted on

How to set up refresh-token-only OAuth for a multi-tenant Apify Actor (Gmail, 10 minutes)

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)

  1. Open console.cloud.google.com, create a new project or pick an existing one.
  2. APIs & Services → Enable APIs → enable Gmail API (or whichever Google API you need).
  3. 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.
  4. APIs & Services → Credentials → Create credentials → OAuth client ID → Desktop app. Download the JSON. You now have client_id and client_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)
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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 }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)