
Building a Secure Token CLI in Python
Learn how to build a secure and reliable Python CLI for token management by combining encrypted storage and robust local key protection.
Introduction
Handling API tokens in CLI tools is a common task for any Python developer — but doing it securely is often overlooked or oversimplified. This article walks through a deep technical exploration of how to safely manage access tokens (like GitHub or AWS keys) in local development environments using Python.
You’ll follow a real-world reasoning path: exploring standard libraries (keyring
, .env
, op
CLI), identifying their limitations, comparing threat models, and finally building a layered, pragmatic solution that balances usability and security.
The Problem
Tokens stored in plaintext or system-unlocked storage are vulnerable to silent reads from malicious code. Solutions like .env
, environment variables, or keyring
default backends are not enough:
- Environment variables can be read by any process owned by the same user.
- Keyring + Keychain on macOS unlocks secrets to all local scripts once the user logs in.
- Encrypted file-based vaults prompt for a password every time, breaking usability.
- Cloud-synced vaults, like 1Password, can be compromised remotely if improperly configured.
There is often a trade-off between usability and security — but they don’t have to be mutually exclusive.
Let’s bust a myth
“It’s just local — so it’s fine if it’s plaintext.”
That assumption is everywhere, and it’s dangerous.
Even in teams that use secure vaults like AWS Secrets Manager or HashiCorp Vault in production, the local development environment remains the weakest link — wide open and mostly ignored.
Being on your local machine does not equal being secure. If a token is in plaintext — in your .env
, shell history, memory, or Keychain without user presence — it’s exposed.
My rule is simple
- Never store secrets in plaintext — not even locally.
- Use a vault — like 1Password — with biometric/user presence required.
- And don’t blindly trust even your vault.
Instead of storing the raw token, store an encrypted blob in your vault. That way, even if someone compromises your vault remotely, your token is still protected — because it was never there in plaintext to begin with.
This simple two-part model gives you protection at both ends:
- The vault protects against local malware and session snooping.
- The local decryption key ensures the vault alone isn’t enough.
No single piece is enough to access your secrets. That’s how security should be.
Option 1: keyring
with macOS Keychain (Default Backend)
import keyring
keyring.set_password("mycli", "github-token", "ghp_xxx123")
token = keyring.get_password("mycli", "github-token")
Pros
- Simple
- Uses OS native Keychain
- No manual password entry
Cons
- Any process during login session can access the token
- No prompt or user presence
- High risk if malware is present locally
Option 2: keyrings.alt.file.EncryptedKeyring
from keyrings.alt.file import EncryptedKeyring
keyring.set_keyring(EncryptedKeyring())
Pros
- Strong AES-256 encryption
- Protects with a master password
Cons
- Prompts for password every script run
- No integration with system biometrics (Touch ID, Windows Hello)
- Inconvenient for repeated CLI usage
Option 3: 1Password CLI (op
) + Plaintext Token
subprocess.check_output(["op", "item", "get", "GitHub Token", "--field", "password"])
Pros
- Touch ID + biometrics for access
- Secrets encrypted and managed externally
- Good developer experience (1Password handles UX)
Cons
- Token is stored in plaintext within 1Password
- Vault compromise = full secret leaked
Optimized Design: Layered Defense with Encrypted Token Blob
What if the 1Password vault only stores an encrypted blob, and your CLI handles the final decryption step with a local-only key?
Design
- Store an AES or Fernet encrypted token blob in 1Password.
- Keep a local-only key on disk (can be public or derived securely).
- Require both vault access + local key to reconstruct the secret.
# Get encrypted blob from 1Password
output = subprocess.check_output([
"op", "item", "get", "GitHub Token Enc", "--field", "password", "--format", "json"
], text=True)
# Local key for final decryption
key = open(".mycli_key", "rb").read()
cipher = Fernet(key)
token = cipher.decrypt(json.loads(output)["value"].encode()).decode()
Why This Works
Risk | Mitigation |
---|---|
Vault leak (cloud, sync compromise) | Encrypted blob stored — token not usable |
Local malware | 1Password prompts Touch ID before read |
Local key theft | Key is useless without unlocking 1Password |
Tradeoff Accepted
- Local key is public on disk (risk accepted)
- 1Password provides the user auth gate
- Token never exists in plaintext unless both conditions are met
Perfect security doesn’t exist — but this model balances practical usability with well-contained risk.
Value for Teams and Hiring
This design shows:
- Security-first engineering thinking
- Practical understanding of threat modeling
- Deep knowledge of platform behavior (macOS Keychain, process access)
- Ability to build layered defenses without impacting developer flow
Authored by Davi Luiz Guides
Part of the “Zero Trust Local Environment” series
Visit daviguides.github.io for more insights