Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Security

Tags:
  • oauth2
  • device-authorization-grant
  • rfc8628
  • cli
  • authentication
  • go

OAuth 2.0 Device Authorization Grant: How CLI Tools Log In Without a Browser Redirect

Run gh auth login on a server you've SSH'd into and it doesn't try to open a browser on that machine. Instead it prints an eight-character code and a URL, and asks you to go and enter that code somewhere else entirely, 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, and 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.

Why the usual flow doesn't work here

The standard authorization code grant goes: 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, which is exactly the situation on a server, a container, a CI runner or a smart TV, and the whole mechanism collapses. There's nowhere for the redirect to land.

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 actual exchange

Three parties: the device (your CLI), the authorization server, and a browser somewhere else that the user controls. The steps:

  1. The device POSTs to the authorization server's device authorization endpoint with its client_id and the scopes it wants.
  2. The server replies with a device_code (long, opaque, for the device's use only), a user_code (short, meant to be typed by a human), a verification_uri, and an interval telling the device how often it's allowed to ask for a token.
  3. The device shows the user the user_code and verification_uri, usually as "go to github.com/login/device and enter ABCD-1234".
  4. The user opens that URL on any device with a browser, logs in if needed, types the code, and approves the request.
  5. Meanwhile the device polls the token endpoint with the device_code every interval seconds, 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, but 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")
}

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, because 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

gh auth login, docker login on some registries, Azure CLI's device login mode, and a fair few smart TV and games console apps all use exactly this flow, because they share the same constraint: no way to run a local web server that a browser can redirect back to, or in the TV/console case, a browser that's technically present but miserable to type a password into. 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 phishing angle worth knowing about

This flow has one property that's genuinely awkward from a security standpoint: the code that gets shown to the user carries no cryptographic binding to the device that requested it, and the human approving it has no way to verify that the thing asking for access is the CLI tool they think it is. Anyone can start a device authorization request against a real authorization server, get back a valid user_code, and then just 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 just authorized the attacker's session, not their own device, and 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, which is about as close to "not theoretical" as it gets.

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, and 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.