OAuth2 Flows for CLI Applications
CLI applications have an authentication problem. They can’t pop up a login form. They shouldn’t ask users to paste access tokens into the terminal. And storing client secrets in distributed binaries is a bad idea.
OAuth2 has several grant types designed for different situations. For CLI apps specifically, there are three options:
- Authorization Code with PKCE: For apps that can open a browser (desktop CLIs)
- Device Flow: For headless environments where you can’t open a browser (SSH, IoT)
- Client Credentials: For machine-to-machine auth where no human is involved (cron jobs, bots)
I built minimal implementations of each to understand how they work.
When to Use Which
| Flow | Context | Use Case |
|---|---|---|
| Authorization Code + PKCE | Desktop CLI | Browser available. The gold standard for CLIs like gh or aws. |
| Device Flow | SSH / Headless / IoT | No browser available. User enters a code on another device. |
| Client Credentials | Cron jobs / Bots | No human user. Machine-to-machine auth. |
PKCE and Device Flow authenticate users without embedding secrets in distributed code. Client Credentials does use a secret, but that’s fine since it runs on your servers, not in binaries users download.
Device Flow
The device flow (RFC 8628) works by having the CLI request a short code, displaying it to the user, and polling until they complete authorization on another device.
The Flow
sequenceDiagram
participant CLI
participant Auth as Auth Server
participant User
CLI->>Auth: POST /device/authorize
Auth-->>CLI: device_code, user_code
Note over CLI: Display to user:
"Go to example.com/device"
"Enter code: ABCD-1234"
User->>Auth: Opens URL, enters code
User->>Auth: Logs in, approves
CLI->>Auth: POST /token (polling)
Auth-->>CLI: authorization_pending
CLI->>Auth: POST /token (polling)
Auth-->>CLI: access_token
Implementation
First, request a device code:
def request_device_code(): response = requests.post( f"{OAUTH_SERVER}/device/authorize", data={ "client_id": CLIENT_ID, "scope": "openid profile", }, ) response.raise_for_status() return response.json()The server returns a device_code (for the CLI to use), a user_code (for the human to type), a verification_uri, and an interval for polling.
Display the instructions:
print(f"Open this URL in any browser:")print(f" {device_response['verification_uri']}")print(f" Enter code: {device_response['user_code']}")Then poll until the user completes authorization:
def poll_for_token(device_code, interval=5, expires_in=300): start_time = time.time()
while True: if time.time() - start_time >= expires_in: raise Exception("Device code expired")
response = requests.post( f"{OAUTH_SERVER}/token", data={ "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "client_id": CLIENT_ID, "device_code": device_code, }, )
if response.status_code == 200: return response.json()
error = response.json().get("error")
if error == "authorization_pending": time.sleep(interval) continue
if error == "slow_down": interval += 5 time.sleep(interval) continue
if error == "access_denied": raise Exception("Authorization denied by user")
if error == "expired_token": raise Exception("Device code expired")The polling loop handles all the standard error responses:
authorization_pending: User hasn’t completed the flow yet, keep waitingslow_down: Polling too fast, back offaccess_denied: User explicitly denied the requestexpired_token: Took too long, start over
Why It’s Secure
The device flow works because:
- No redirect URI needed. The device never opens ports or receives callbacks.
- Short, typed codes. Users manually enter a short code on a trusted device, making phishing harder.
- Time-limited. Codes expire quickly, limiting the window for attacks.
- Polling-based. The device initiates all requests. It never receives unsolicited connections.
Authorization Code with PKCE
PKCE (Proof Key for Code Exchange, RFC 7636) was designed for mobile apps but works perfectly for CLI tools. It replaces the client secret with a dynamically generated code verifier that proves the token request came from the same client that started the flow.
The Flow
sequenceDiagram
participant CLI
participant Auth as Auth Server
participant Browser
Note over CLI: Generate verifier
Hash to challenge
CLI->>Browser: Open browser (with challenge)
Browser->>Auth: Authorization request
Auth->>Browser: Login page
Browser->>Auth: User logs in, approves
Auth->>CLI: Callback to localhost (with auth code)
CLI->>Auth: POST /token (with verifier)
Auth-->>CLI: access_token
Implementation
Generate a PKCE pair:
def generate_pkce_pair(): code_verifier = secrets.token_urlsafe(32) digest = hashlib.sha256(code_verifier.encode()).digest() code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() return code_verifier, code_challengeThe verifier is a random string. The challenge is its SHA256 hash, base64-encoded. You send the challenge with the authorization request and prove you have the verifier when exchanging the code.
Build the authorization URL and open it:
state = secrets.token_urlsafe(16)
auth_params = urllib.parse.urlencode({ "client_id": CLIENT_ID, "redirect_uri": "http://localhost:8085/callback", "response_type": "code", "scope": "openid profile", "state": state, "code_challenge": code_challenge, "code_challenge_method": "S256",})
webbrowser.open(f"{OAUTH_SERVER}/authorize?{auth_params}")The state parameter prevents CSRF attacks. You verify it matches when the callback arrives.
Listen for the callback on localhost:
class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): parsed = urllib.parse.urlparse(self.path) if parsed.path == "/callback": query = urllib.parse.parse_qs(parsed.query) self.server.auth_code = query.get("code", [None])[0] self.server.state = query.get("state", [None])[0] self.server.error = query.get("error", [None])[0]
self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write(b"<html><body>Authentication successful!</body></html>")
def wait_for_callback(port): server = http.server.HTTPServer(("127.0.0.1", port), OAuthCallbackHandler) server.handle_request() return server.auth_code, server.state, server.errorAfter the user authorizes, the browser redirects to localhost:8085/callback with an authorization code.
Validate the state and exchange the code:
if returned_state != state: raise Exception("State mismatch - possible CSRF attack")
response = requests.post( f"{OAUTH_SERVER}/token", data={ "grant_type": "authorization_code", "code": auth_code, "redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID, "code_verifier": code_verifier, },)The server hashes the verifier you send and compares it to the challenge from the original request. If they match, it knows this token request came from the same client that started the flow.
Why PKCE Matters
Without PKCE, authorization code flow requires a client secret. But CLI apps are public clients -you can’t embed secrets in distributed binaries. Anyone could extract them.
PKCE solves this by replacing the static secret with a dynamic one. Even if an attacker intercepts the authorization code (via a malicious redirect or browser history), they can’t exchange it without the verifier, which never leaves the CLI.
Client Credentials
The client credentials grant is the simplest OAuth2 flow. There’s no user, no browser, no redirects. Just the client authenticating itself directly.
The Flow
sequenceDiagram
participant CLI
participant Auth as Auth Server
CLI->>Auth: POST /token (client_id, client_secret)
Auth-->>CLI: access_token
That’s it. One request, one response.
Implementation
def get_access_token(): response = requests.post( f"{OAUTH_SERVER}/token", data={ "grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "scope": "api:read api:write", }, ) response.raise_for_status() return response.json()The server validates the credentials and returns an access token. No refresh token -when it expires, you just request another one.
When to Use It
Client credentials is for machine-to-machine authentication:
- Cron jobs that call APIs
- Background workers processing queues
- CI/CD pipelines deploying code
- Service accounts for monitoring
The key requirement is that there’s no human user involved. The client is the resource owner.
Why It Uses a Secret
Unlike PKCE and device flow, client credentials does use a client secret. That’s fine because it runs in trusted environments -your servers, your CI system, your infrastructure. The secret never leaves your control.
Don’t use client credentials for distributed CLI tools. If users download your binary, they can extract the secret. Use PKCE or device flow instead.
Testing the Implementations
All implementations include a test suite and a Docker Compose setup with a mock OAuth2 server:
# Start the mock serverdocker compose up -d
# Run the CLIcd clipip install -r requirements.txtpython cli.py
# Run testspytest tests/ -vThe mock server (mock-oauth2-server) implements the full OAuth2 spec, including the device flow endpoints. It’s useful for testing without setting up real provider credentials.
Beyond Authorization
The three flows above cover CLI authentication. But OAuth2 has extensions for identity and delegation.
OpenID Connect
OpenID Connect (OIDC) is a layer on top of OAuth2 that adds identity. Where OAuth2 answers “is this client allowed to access this API?”, OIDC answers “who is this user?”
The flow is identical to authorization code with PKCE, but you request the openid scope and get back an ID token in addition to the access token:
auth_params = urllib.parse.urlencode({ "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI, "response_type": "code", "scope": "openid profile email", # OIDC scopes "state": state, "code_challenge": code_challenge, "code_challenge_method": "S256",})The ID token is a JWT containing claims about the user:
{ "sub": "user-123", "name": "Corey Burmeister", "iat": 1699900000, "exp": 1699903600}You can also hit the /userinfo endpoint with the access token to get the same information. The ID token is useful when you need to verify identity without an extra network call.
Token Exchange
Token exchange (RFC 8693) lets you trade one token for another. The canonical use case is microservices: Service A receives a user’s token, but needs to call Service B on their behalf. Rather than forwarding the original token (which might have too many permissions), Service A exchanges it for a new token scoped specifically for Service B.
response = requests.post( f"{OAUTH_SERVER}/token", data={ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token": original_access_token, "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", "audience": "service-b", "scope": "service-b:read", },)The authorization server validates the original token, checks that the exchange is allowed, and issues a new token with the requested scope and audience. This maintains the principle of least privilege across service boundaries.
Token exchange is also useful for impersonation (admin acting as user) and delegation (user granting limited access to a third party).
Final Thoughts
OAuth2 gets a reputation for being complicated, and the spec is dense. But the actual implementations are straightforward once you understand which flow to use.
The key insight is that each flow is designed for a specific trust model:
- PKCE replaces static secrets with dynamic ones for public clients
- Device flow uses short-lived codes for environments without browsers
- Client credentials uses secrets because the client runs in a trusted environment
- OIDC adds identity claims to the authorization flow
- Token exchange maintains least privilege across service boundaries
Pick the flow that matches your context, and the implementation follows naturally.