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_tokenprinted 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.
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
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.
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.
Why does the chapter tell you to accept the refresh token from stdin rather than pass it on the command line?
Which flag makes security add-generic-password update an existing entry instead of erroring?
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”