On this page

Chapter 4 of 8. This chapter is self-contained. If you want the full chronological build-up: Chapter 1 (the motivation), Chapter 2 (Google Cloud project + enabling the APIs), and Chapter 3 (the OAuth 2.0 protocol walkthrough) are the prerequisites. For now, what you need to know: this chapter assumes you've already created an OAuth client in Google Cloud Console (Desktop application type) and completed the consent flow once, which gave you a refresh_token printed to stdout. The whole chapter is about what to do with it next.

Why the refresh token deserves better than .env

A Google OAuth refresh token is a long-lived bearer credential. Anyone holding it can mint access tokens for your account, against any scope that was authorised, without your involvement. The default install-script advice — write it to a .env file — works, but it leaves the credential in plaintext on disk, readable by every process that runs under your user.

macOS Keychain solves three problems at once: the credential is encrypted at rest behind your login keychain's master key; the OS gates access per-application (per RFC 6749 §10.4, refresh-token storage is left to the implementer, and Keychain is the documented Apple-blessed implementation); and the security CLI gives you a stable interface that any process can shell out to without linking against a framework.

// DECISION

Why Keychain, not a token file?

The call

The refresh token lives only in macOS Keychain, written and read through the security CLI. It exists nowhere on disk, in no dotfile, and in no repository history.

Rejected
token.json / .env on diskone gitleaks miss or one over-broad backup and a long-lived credential is out
Environment variablesvisible to process listings and shell history, and still sourced from a file somewhere
A service-account keyswaps a user token for a JSON key file, which is a heavier secret to protect, not a lighter one
What it costs

The tooling is macOS-specific, and headless re-auth needs care: an interactive prompt that expects a terminal will fail inside automation.

Revisit when

If this ever runs on Linux or CI, the Keychain calls become an OS-keyring abstraction. The rule that the token never touches disk survives the port.

Writing the token in

The security add-generic-password subcommand creates an entry under your default keychain. Pick a service name that disambiguates this token from anything else in there:

security add-generic-password \
  -s "gsc-telemetry:captain_random:refresh_token" \
  -a "$USER" \
  -w "$REFRESH_TOKEN" \
  -U  # update if it already exists

The -U flag matters more than it looks. Without it, re-running the install script after a token rotation throws The specified item already exists in the keychain. (per Apple's Keychain Services error reference). With it, the entry is atomically replaced.

Reading the token from Python

Shell out to security find-generic-password from Python via subprocess.run (see the subprocess module docs for the security semantics of arguments-as-list). The -w flag prints the password to stdout; everything else stays on stderr:

import subprocess

def read_refresh_token() -> str:
    result = subprocess.run(
        [
            "security",
            "find-generic-password",
            "-s",
            "gsc-telemetry:captain_random:refresh_token",
            "-a",
            os.getenv("USER"),
            "-w",
        ],
        capture_output=True,
        text=True,
        check=True,
    )
    return result.stdout.strip()

The first call after a fresh shell session triggers a Keychain access prompt. Tick "Always Allow" only if this script will run unattended (per Apple's docs, this stores a permanent access entry against the specific binary path; renaming the script invalidates it).

Refresh-to-access exchange

With the refresh token in hand, the google-auth library handles the access-token mint. Per RFC 6749 §6, you exchange grant_type=refresh_token plus your client credentials for a fresh access token; google-auth wraps the call:

from google.oauth2.credentials import Credentials

creds = Credentials(
    token=None,                       # access token — filled by refresh()
    refresh_token=read_refresh_token(),
    token_uri="https://oauth2.googleapis.com/token",
    client_id=os.environ["GOOGLE_OAUTH_CLIENT_ID"],
    client_secret=os.environ["GOOGLE_OAUTH_CLIENT_SECRET"],
    scopes=[
        "https://www.googleapis.com/auth/webmasters.readonly",
        "https://www.googleapis.com/auth/analytics.readonly",
    ],
)
creds.refresh(google.auth.transport.requests.Request())

Access tokens last one hour. The Credentials object handles re-minting transparently when the access token expires mid-script — but only if refresh_token is set, and only if client_id + client_secret are also passed (per Google's OAuth docs, the client secret is part of the refresh exchange).

What to do when the token gets revoked

Refresh tokens don't expire on a schedule but they can be revoked: by the user from their Google account page, by Google's automatic policies (six months of inactivity, scope changes, password reset), or by your application calling revoke(). When that happens, creds.refresh() raises RefreshError. The right response is to surface a clear error and re-run the consent flow — the refresh token can't be recovered.

Build that into your CLI as a --reauth subcommand. The pattern: delete the Keychain entry (security delete-generic-password -s "..."), run the InstalledAppFlow once more, prompt for the new token, write it back to Keychain. The whole cycle takes about thirty seconds and is the only time the user sees the consent screen after first install.

// EXERCISE

Move a real credential into Keychain

Take a long-lived secret from one of your own projects, an OAuth refresh token or an API key currently sitting in a plaintext dotfile, and rehouse it with this chapter's pattern: written via the security CLI without touching shell history, read from Python via subprocess, with a rehearsed re-auth path for the day it gets revoked.

Expected behaviour
  • The secret stored under a service name that disambiguates project, account and credential type
  • The write accepts the secret from stdin so it never lands in shell history
  • Re-running the write with -U replaces the entry instead of failing with the already-exists error
  • A Python helper that reads the secret back via subprocess.run using the args-as-list form with check=True
  • The old plaintext copy deleted from disk once the Keychain read is proven working

PROVE IT Demonstrate the full rotation drill live: delete the entry with security delete-generic-password, restore it from stdin, and show the Python helper reading it back.

// CHECKPOINT — KEYCHAIN
multiple choice · auto-checked

Why does the chapter tell you to accept the refresh token from stdin rather than pass it on the command line?

exact answer · auto-checked

Which flag makes security add-generic-password update an existing entry instead of erroring?

open · self-checked

Months after install, creds.refresh() raises RefreshError. What has probably happened, and what should your CLI offer in response?

Show answer

The refresh token has been revoked: by the user from their Google account page, by Google's automatic policies such as six months of inactivity, a scope change or a password reset, or by an explicit revoke call. It cannot be recovered, so the CLI should offer a reauth subcommand that deletes the Keychain entry, runs the InstalledAppFlow once more, and writes the new token back.

↺ re-read: “What to do when the token gets revoked

Lived experience

Sources

  • Using OAuth 2.0 for Web Server Applications
    Google
    Google's OAuth 2.0 implementation reference
    developers.google.com
  • RFC 6749: The OAuth 2.0 Authorization Framework
    IETF
    The protocol this chapter implements
    datatracker.ietf.org
  • Keychain Services
    Apple Developer Documentation
    The storage layer used by `security add-generic-password`
    developer.apple.com
  • subprocess — Subprocess management
    Python Software Foundation
    How Python shells out to the `security` CLI
    docs.python.org
  • google-auth library documentation
    Google (google-auth library docs)
    Refresh token handling in the official Python library
    google-auth.readthedocs.io
Back to guide overview