SaaS CLI Authentication Flow
The One Rule: A CLI user must authenticate against a web service without ever
exposing credentials in transit, in logs, or at rest — and it must work whether the
user has a local browser, is SSH'd into a remote server, or is running in CI.
The Three-Tier Auth Architecture
Every SaaS CLI needs three authentication paths. Users don't choose — the CLI
auto-detects the environment and picks the best available tier.
Tier 1: Browser PKCE Tier 2: Manual PKCE Tier 3: Device Code (RFC 8628)
(local machine + browser) (SSH/headless + copy-paste) (fully headless, no loopback)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ CLI binds │ │ CLI prints │ │ CLI requests │
│ localhost │ │ auth URL │ │ device code │
│ :random_port │ │ to stderr │ │ from server │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ opens browser │ user opens │ displays
▼ │ URL manually ▼ ABCD-1234
┌──────────────┐ ┌──────┴───────┐ ┌──────────────┐
│ Web login │ │ Web login │ │ User visits │
│ (Google, etc)│ │ + callback │ │ /verify page │
└──────┬───────┘ │ page shows │ │ enters code │
│ redirect to │ copy button │ └──────┬───────┘
│ localhost:port └──────┬───────┘ │ server marks
▼ │ user pastes │ code verified
┌──────────────┐ ┌──────┴───────┐ ┌──────────────┐
│ CLI receives │ │ CLI parses │ │ CLI polls │
│ code+state │ │ pasted URL │ │ until verified│
│ on callback │ │ from stdin │ │ then exchanges│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
▼ ▼ ▼
──── All three converge on token minting (same output) ────
Tier 1+2: POST /api/v1/auth/token
Tier 3: POST /api/v1/auth/device-token
→ { access_token, refresh_token }
Environment Detection Logic
is_interactive()?
├── NO → Error: "Cannot authenticate in non-interactive mode. Use API key."
└── YES
├── --remote flag? → Tier 3 (device code)
├── --manual flag? → Tier 2 (manual PKCE)
└── auto-detect:
├── SSH_CLIENT or SSH_TTY set? → Tier 3
├── Linux: DISPLAY or WAYLAND_DISPLAY set? → Tier 1
├── macOS: always has GUI → Tier 1
├── Windows: always has GUI → Tier 1
└── fallback → Tier 2
The Implementation Loop
1. Design protocol doc first (endpoints, error codes, security reqs)
2. Implement Tier 1 (browser PKCE) — the happy path
3. Add Tier 2 (manual paste) — minimal delta from Tier 1
4. Add Tier 3 (device code) — independent flow, new endpoints
5. Harden: timing-safe comparisons, replay prevention, race conditions
6. Add token refresh + revocation
7. Add secure credential storage (keyring → encrypted file fallback)
8. Iterate: abuse tracking, SSO enforcement, suspension checks
Security Invariants (Non-Negotiable)
| Invariant |
Why |
How |
| PKCE (RFC 7636) on every code exchange |
Prevents authorization code interception |
SHA-256 challenge/verifier pair |
| CSRF state token |
Prevents cross-site request forgery |
Random 22-128 char token, verified on callback |
| One-time auth codes |
Prevents replay attacks |
Advisory lock on code fingerprint, check before mint |
| Timing-safe comparison |
Prevents timing side-channels |
crypto.timingSafeEqual() for all token/hash comparisons |
| Tokens never in URLs or logs |
Prevents credential leakage |
Bearer header only, mask in logs: jsm_abc...xyz |
| Short-lived auth codes |
Limits window for stolen codes |
60-second expiry on authorization codes |
| Hash tokens before storage |
Database breach doesn't leak tokens |
SHA-256 hash in DB, never store raw token |
| Atomic operations |
Prevents race conditions |
DB advisory locks for concurrent refresh/exchange |
Token Format Convention
<prefix>_<random_hex>
Access: <prefix>_<64 hex chars> (32 random bytes)
Refresh: <prefix>_refresh_<64 hex chars> (distinct prefix prevents misuse)
API Key: <prefix>_<64 hex chars> (same format as access token;
distinguished by issuance context,
not prefix — both use server-side lookup)
Validation regex: /^jsm_[a-f0-9]{64}$/ (access), /^jsm_refresh_[a-f0-9]{64}$/ (refresh)
Quick Reference: Endpoints
| Endpoint |
Method |
Purpose |
Auth Required |
/api/v1/auth/cli-login |
GET |
Initiate browser PKCE flow |
No (sets cookie) |
/api/v1/auth/callback |
GET |
Generate auth code after login |
Web session |
/api/v1/auth/token |
POST |
Exchange auth code for tokens |
No (code + verifier) |
/api/v1/auth/device-code |
POST |
Create device code |
No |
/api/v1/auth/device-verify |
POST |
User verifies device code |
Web session |
/api/v1/auth/device-token |
POST |
CLI exchanges verified device code |
No (device_code) |
/api/v1/auth/refresh |
POST |
Refresh access token |
Refresh token |
/api/v1/auth/revoke |
POST |
Revoke token (logout) |
Token being revoked |
Anti-Patterns (Never Do)
| Anti-Pattern |
Why It Fails |
Do Instead |
| Store raw tokens in DB |
Database breach = full compromise |
Hash with SHA-256, store hash only |
| Use timestamps as state tokens |
Predictable, replayable |
Cryptographic random bytes |
| Skip PKCE for "internal" CLIs |
Same attack surface exists |
Always PKCE, even internal tools |
| Retry auth code exchange on failure |
Enables replay attacks |
Fail permanently, user re-authenticates |
| Poll device-token without backoff |
Server overload, rate limiting |
Exponential backoff, honor slow_down |
| Single token for access + refresh |
Can't rotate access without losing refresh |
Distinct prefixes, independent lifecycle |
| Use JWT for CLI tokens |
Can't revoke, clock skew issues |
Opaque tokens + server-side lookup |
| Embed secrets in callback URLs |
URL logging, browser history, referrer leakage |
Signed short-lived codes only |
| Hard-code localhost port |
Port conflicts across CLI instances |
Bind to 127.0.0.1:0, OS assigns port |
Check is_authenticated with keyring prompt |
Interactive keyring dialog during background ops |
Try keyring read, suppress prompts in non-login paths |
Credential Storage Hierarchy
1. OS Keyring (macOS Keychain, Linux Secret Service, Windows Credential Manager)
↓ (fails silently if unavailable)
2. Encrypted file: ~/.config/<cli>/credentials.json
• AES-256-GCM with PBKDF2 key derivation
• Base64url-encoded {salt, nonce, ciphertext}
↓ (last resort)
3. Error: inform user, suggest API key as alternative
Checklist: Before You Ship
References
| Need |
Reference |
| Full PKCE browser flow |
PKCE-FLOW.md |
| Device code flow (RFC 8628) |
DEVICE-CODE-FLOW.md |
| Token lifecycle (mint/refresh/revoke) |
TOKEN-LIFECYCLE.md |
| Server-side implementation |
SERVER-IMPLEMENTATION.md |
| CLI-side implementation (Rust) |
CLI-IMPLEMENTATION.md |
| Credential storage |
CREDENTIAL-STORAGE.md |
| Web UI for device verification |
DEVICE-VERIFY-UI.md |
| Database schema |
SCHEMA.md |
| Security hardening |
SECURITY-HARDENING.md |
| Protocol design template |
PROTOCOL-TEMPLATE.md |
| Headless/SSH detection |
ENVIRONMENT-DETECTION.md |
| Testing strategies |
TESTING.md |
| Anti-patterns deep dive |
ANTI-PATTERNS.md |
| Evolution & lessons learned |
LESSONS-LEARNED.md |