How CLI Tools Log You In with a Code on Another Device
Run gh auth login on a server you've SSH'd into and it doesn't try to open a browser on that machine. It prints an eight-character code and a URL, and asks you to enter that code on whatever device you actually have a browser open on.
That's not a hack GitHub invented. It's a proper OAuth 2.0 grant type, specified in RFC 8628.
It exists because the normal authorization code flow has a hard requirement that a headless box simply can't meet: a redirect URI the authorization server can send the browser back to.
The usual flow needs a browser on the same machine
The standard authorization code grant goes like this:
- Redirect the user's browser to the authorization server.
- They log in and consent.
- The authorization server redirects back to your app with a code in the query string.
- Your app exchanges that code for a token.
Every step assumes the thing initiating the flow and the thing receiving the redirect are the same browser, on the same machine, at the same time.
Desktop CLIs get around this by opening a local HTTP listener on 127.0.0.1 and using that as the redirect URI, then popping a browser pointed at the authorization server. gcloud auth login does this.
It works because there's a display and a browser to pop, and the loopback address is reachable because it's the same machine. Take away the display and the whole mechanism collapses; there's nowhere for the redirect to land. That's exactly the situation on:
- a server
- a container
- a CI runner
- a smart TV
The device flow solves this by decoupling the two halves entirely. The device polls for a token; a human, on a completely different piece of hardware with a normal browser, does the actual authorization. No redirect URI is needed anywhere in the exchange.
The exchange, step by step
Three parties: the device (your CLI), the authorization server, and a browser somewhere else that the user controls. The steps:
- The device
POSTs to the authorization server's device authorization endpoint with itsclient_idand the scopes it wants. - The server replies with a
device_code(long, opaque, for the device's use only), auser_code(short, meant to be typed by a human), averification_uri, and anintervaltelling the device how often it's allowed to ask for a token. - The device shows the user the
user_codeandverification_uri, usually as "go to github.com/login/device and enter ABCD-1234". - The user opens that URL on any device with a browser, logs in if needed, types the code, and approves the request.
- Meanwhile the device polls the token endpoint with the
device_codeeveryintervalseconds, until it gets back an access token or a terminal error.
Nothing about this requires the polling device and the approving browser to share a network, a session, or even a country. That's the whole point.
A minimal Go client
Here's what the two HTTP legs look like in practice. This omits config file caching and refresh token handling to keep the flow itself visible.
The request and response shapes are exactly what a real authorization server (GitHub, Google, Auth0, Okta, Microsoft Entra) returns.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
type deviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
}
func requestDeviceCode(ctx context.Context, authURL, clientID, scope string) (*deviceAuthResponse, error) {
form := url.Values{"client_id": {clientID}, "scope": {scope}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = form.Encode()
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("device authorization request: %w", err)
}
defer resp.Body.Close()
var out deviceAuthResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decoding device authorization response: %w", err)
}
return &out, nil
}
func pollForToken(ctx context.Context, tokenURL, clientID string, auth *deviceAuthResponse) (*tokenResponse, error) {
interval := time.Duration(auth.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}
deadline := time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
for {
if time.Now().After(deadline) {
return nil, fmt.Errorf("device code expired before authorization completed")
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
form := url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {auth.DeviceCode},
"client_id": {clientID},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = form.Encode()
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("polling token endpoint: %w", err)
}
var tok tokenResponse
decErr := json.NewDecoder(resp.Body).Decode(&tok)
resp.Body.Close()
if decErr != nil {
return nil, fmt.Errorf("decoding token response: %w", decErr)
}
switch tok.Error {
case "":
return &tok, nil
case "authorization_pending":
continue
case "slow_down":
interval += 5 * time.Second
continue
case "access_denied":
return nil, fmt.Errorf("user denied the authorization request")
case "expired_token":
return nil, fmt.Errorf("device code expired")
default:
return nil, fmt.Errorf("token endpoint returned error: %s", tok.Error)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
auth, err := requestDeviceCode(ctx, "https://example.com/oauth/device/code", "cli-client-id", "read:user")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("Go to %s and enter code: %s\n", auth.VerificationURI, auth.UserCode)
tok, err := pollForToken(ctx, "https://example.com/oauth/token", "cli-client-id", auth)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("got access token, expires in", strconv.Itoa(tok.ExpiresIn), "seconds")
}
Honour slow_down, or get rate-limited
The bit worth noticing is slow_down. The spec requires the client to back off by at least five seconds every time it gets one, rather than just retrying at the same interval.
Why so strict? A client that ignores it and keeps hammering at the original rate is indistinguishable from someone trying to brute-force the flow.
It's a small detail, but it's the difference between a compliant client and one that gets rate-limited into uselessness the moment the server is under load.
Where this actually gets used
All of these use exactly this flow:
gh auth logindocker loginon some registries- Azure CLI's device login mode
- a fair few smart TV and games console apps
They share the same constraint: no way to run a local web server that a browser can redirect back to. In the TV/console case, there's a browser that's technically present but miserable to type a password into.
Actually, that TV case is a nice illustration of how humane this flow is. Typing a six or eight character code with a remote control is a much smaller ask than typing an email address and password with the same remote.
The code isn't bound to the device, which invites phishing
This flow has one genuinely awkward security property. The code shown to the user carries no cryptographic binding to the device that requested it.
The human approving it has no way to verify that the thing asking for access is the CLI tool they think it is. The attack writes itself:
- Anyone can start a device authorization request against a real authorization server and get back a valid
user_code. - They ask a victim to go and enter it, for example by pasting "please verify your account, go to microsoft.com/devicelogin and enter this code" into a chat message or email.
- If the victim complies, they've authorized the attacker's session, not their own device.
- The attacker walks away with a working access token issued by the real identity provider.
Microsoft's threat intelligence team documented exactly this technique being used in a real phishing campaign against Microsoft 365 accounts in early 2025. That's about as close to "not theoretical" as it gets.
The fixes live on the authorization server
The mitigations that actually help are on the authorization server side rather than the client side:
- short device code and user code lifetimes
- rate limiting on the authorization endpoint
- showing the requesting application's name and any known context (IP, location) on the consent screen, so the user has something to sanity-check against
- treating an unusually high volume of device authorization requests from a single client as worth alerting on
If you're implementing an authorization server rather than just a client, RFC 8628's security considerations section is short and worth reading in full rather than skimming.
None of this makes the grant type a bad choice for a CLI tool. It's the only sane option once you've accepted that the device has no display worth trusting.
It just means the interesting security properties live on the server issuing the codes, not in the sixty-odd lines of polling logic on the client end.