{"slug": "cli-authentication-the-right-way", "title": "CLI Authentication, the Right Way", "summary": "CLI authentication flows that rely on localhost redirects and browser availability fail in SSH sessions, containers, and WSL environments. The author argues that RFC 8252's OAuth pattern for native apps does not address headless hosts, and most vendor CLIs have not adopted device-code flow or other solutions since 2019.", "body_md": "[CLI Authentication, the Right Way](https://www.abgeo.dev/blog/cli-authentication-the-right-way/)\n\nI SSH into a fresh dev VM and run `claude`\n\nto start a session in there. The CLI prints a login URL\nwith `http://localhost:54213/callback`\n\nburied in the query string, tries to open a browser on the\nremote box, and starts waiting for a callback. There is no browser on this box. The CLI catches the\nfailure, prints `Paste code here if prompted`\n\n, and hangs. I copy the URL into the browser on my\nlaptop, log in, and the consent page hands me a one-time code instead of a redirect. I paste it back\nover SSH. It works. It is also 2009 wearing a 2026 t-shirt.\n\nThis is a solved problem. It has been solved since 2019. Most CLIs still have not caught up.\n\n## What most tools actually do[#](#what-most-tools-actually-do)\n\nThe pattern is everywhere. `gcloud auth login`\n\n, `wrangler login`\n\n, the older `vercel login`\n\n, and a\nlong tail of vendor CLIs all run the same dance:\n\n- The CLI binds an HTTP server on\n`127.0.0.1`\n\nat some port. Wrangler picks 8976. gcloud uses 8085. Claude Code grabs an ephemeral one each invocation. - It opens your system browser to the OAuth authorization endpoint with\n`redirect_uri=http://127.0.0.1:<port>/callback`\n\n. - You log in. The provider 302s back to the loopback URL with an authorization code.\n- The CLI’s tiny HTTP server picks up the request, reads the code, exchanges it at the token\nendpoint (usually with\n[PKCE](https://datatracker.ietf.org/doc/html/rfc7636)attached), and shuts down. - You see the “you can close this tab” page that every CLI ships.\n\nOn a laptop it wraps up in about five seconds.\n[RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252), the BCP for OAuth in native apps,\nendorses this pattern when the app has a browser available, and for a developer running everything\non one machine, it is a good fit. What 8252 does not address is what to do when there isn’t a\nbrowser on the host. The rest of this post is about exactly that case.\n\n## Why you have probably never noticed[#](#why-you-have-probably-never-noticed)\n\nThe localhost step is invisible. The CLI prints a URL long enough that nobody reads it, but the redirect URI is sitting right there in the query string:\n\nYou click through, log in on the provider’s real domain, and approve. The provider 302s your browser to the localhost callback. The CLI’s tiny HTTP server reads the code, then immediately bounces you to a polished “you’re signed in” page back on the provider’s actual website. The localhost URL flashes in your address bar for a hundred milliseconds before the final redirect lands you here:\n\nIf you blink you miss it. Most users never realize their CLI bound a local HTTP server at all. The flow looks like “log in on a website, the CLI just knows”, and the illusion holds right up until the moment you try to use the CLI without a browser sitting next to it. The same design choice that builds the illusion is the one that breaks the flow.\n\n## Where it breaks[#](#where-it-breaks)\n\nThe whole thing rests on one assumption: the machine running the CLI is the machine running the browser. Once that stops being true, the dance falls apart.\n\n**SSH sessions.** No browser on the remote host.`xdg-open`\n\neither errors out or, with X forwarding on, opens a browser on the remote box that you cannot see. You can tunnel the callback port back to your laptop, but then the redirect URI registered with the provider has to allow whatever port survives the tunnel. Most setups don’t.**Containers.** No browser inside, and most images don’t even ship`xdg-open`\n\nor`open`\n\n. You can punch the callback port through with`-p`\n\n, but only if you knew which port the CLI was going to grab. Cloudflare’s CLI has a long trail of[issues](https://github.com/cloudflare/workers-sdk/issues/862)from people stuck on exactly this.**WSL.** The browser opens on Windows. The loopback server runs on Linux. WSL2’s port forwarding gets it right most of the time. “Most” is the keyword.**Shared boxes.** Anything else on that machine can read`/proc/net/tcp`\n\nto find the listening port, or race to bind a known one. PKCE protects the code exchange. It does not protect the user’s authenticated session on the redirect itself.\n\nEvery CLI that ships this flow also ships a fallback for when it breaks. gcloud has\n`--no-launch-browser`\n\n. Wrangler hangs, and the\n[accepted workaround](https://github.com/cloudflare/workers-sdk/issues/862) is to curl the localhost\nURL from a second terminal yourself. Anthropic’s `claude`\n\nprints “Paste code here if prompted” and\nwaits. These are all manual device flows in disguise. They exist because the real flow does not work\nwhere the CLI is actually being used.\n\n## The grant they should be using[#](#the-grant-they-should-be-using)\n\nThe OAuth 2.0 Device Authorization Grant, [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628),\nwas published in 2019 for what the spec calls “input-constrained devices”. TVs, consoles, and yes,\nCLIs. The whole point is to decouple the device asking for the token from the device the user\nauthenticates on.\n\nThe protocol is short.\n\nThe CLI starts by POSTing to the provider’s `device_authorization_endpoint`\n\n:\n\n```\nPOST /oauth/device/code HTTP/1.1\nHost: provider.example.com\nContent-Type: application/x-www-form-urlencoded\n\nclient_id=my-cli&scope=openid+offline_access\n```\n\nThe provider answers with JSON straight out of the spec:\n\n```\n{\n  \"device_code\": \"GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS\",\n  \"user_code\": \"WDJB-MJHT\",\n  \"verification_uri\": \"https://provider.example.com/device\",\n  \"verification_uri_complete\": \"https://provider.example.com/device?user_code=WDJB-MJHT\",\n  \"expires_in\": 1800,\n  \"interval\": 5\n}\n```\n\nThe CLI prints the URL and the short code (and ideally a QR for `verification_uri_complete`\n\n), then\nstarts polling the token endpoint every `interval`\n\nseconds:\n\n```\nPOST /oauth/token HTTP/1.1\nHost: provider.example.com\nContent-Type: application/x-www-form-urlencoded\n\ngrant_type=urn:ietf:params:oauth:grant-type:device_code\n&device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS\n&client_id=my-cli\n```\n\nThe user opens the URL on whatever device they want, not necessarily the box running the CLI. They log in, see the requested scopes and the client name, confirm the short code matches what the CLI printed, and approve.\n\nWhile that happens, the polling responses cycle through the states the spec defines in\n[section 3.5](https://datatracker.ietf.org/doc/html/rfc8628#section-3.5): `authorization_pending`\n\nwhile you wait, `slow_down`\n\nif the provider wants you to back off (the spec is explicit: bump the\ninterval by at least 5 seconds), `access_denied`\n\nif the user said no, `expired_token`\n\nif you sat on\nit too long. Eventually you get a real token response.\n\nThat is the whole protocol. The CLI never binds a port, and never assumes the host it runs on has a browser sitting next to it. The same login works from a laptop, from a container, and from a CI job that pauses to wait for a human to approve.\n\nThe polling will look old-fashioned to some readers, and the first reaction I get when I show this\nto people is “isn’t that hammering the auth server?”. It is not. The spec defaults to a five-second\ninterval. Most authorizations complete in well under a minute, so a typical login fires somewhere on\nthe order of ten polls to `/token`\n\nand then stops. The server is in control of the rate: `slow_down`\n\nexists specifically so the provider can push the interval up when it is under load, and a\nwell-written client has to honor it. Compare that to holding a WebSocket or an SSE connection open\nper pending login, against a stateful endpoint, for the entire authorization window. Stateless\npolling against `/token`\n\nis cheaper and simpler, and the same providers handling millions of token\nrefreshes a day do not break a sweat over it.\n\nIf the provider supports\n[OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html), the CLI can\npull the `device_authorization_endpoint`\n\nand `token_endpoint`\n\nstraight out of\n`.well-known/openid-configuration`\n\nand stop hardcoding URLs entirely.\n\n## What about phishing?[#](#what-about-phishing)\n\nThe device flow has its own attack worth naming. An attacker can call the real provider’s\n`device_authorization_endpoint`\n\nthemselves, get back a real `user_code`\n\nand `device_code`\n\n, then send\nthe victim a phishing message: “Your IT team needs you to authorize a new device. Go to\n`microsoft.com/devicelogin`\n\nand enter `WDJB-MJHT`\n\n.” The URL is real. The code is real. The victim\ntypes it, logs in with real credentials, approves a real consent screen. The attacker, who has been\npolling `/token`\n\nwith the `device_code`\n\nthey generated, receives the access token. Russian threat\nactors ran exactly this campaign against M365 tenants from August 2024 onwards, tracked by\n[Microsoft Threat Intelligence as Storm-2372](https://www.microsoft.com/en-us/security/blog/2025/02/13/storm-2372-conducts-device-code-phishing-campaign/)\nand attributed by\n[Volexity to APT29/Midnight Blizzard](https://www.volexity.com/blog/2025/02/13/multiple-russian-threat-actors-targeting-microsoft-device-code-authentication/),\nhitting government, defense, and NGO tenants across multiple continents.\n\nThe defense lives on the provider, not the CLI. Short `user_code`\n\nexpiry. A verification page that\nshows the client name and the requesting location prominently. Rate limiting on entry attempts. Not\nexposing `verification_uri_complete`\n\n, so the attacker has to make the victim type the code instead\nof clicking a link. For high-value tenants the real answer is conditional access policies that block\ndevice code flow unless it is coming from a known network or device. The CLI’s job is just to honor\nthe spec and not invent shortcuts.\n\nNone of this is a reason to go back to loopback. The device flow trades a local attack surface for a social one. Every authentication flow makes that tradeoff. The right move is to ship the flow that works in more environments and pick a provider that ships the mitigations.\n\n## A real implementation, top to bottom[#](#a-real-implementation-top-to-bottom)\n\nThe whole thing fits in about 30 lines of Go. No framework, no SDK, just `net/http`\n\n:\n\n```\nform := url.Values{\"client_id\": {clientID}, \"scope\": {\"openid offline_access\"}}\nresp, _ := http.PostForm(meta.DeviceAuthorizationEndpoint, form)\n\nvar auth struct {\n    DeviceCode              string `json:\"device_code\"`\n    UserCode                string `json:\"user_code\"`\n    VerificationURIComplete string `json:\"verification_uri_complete\"`\n    Interval                int    `json:\"interval\"`\n}\njson.NewDecoder(resp.Body).Decode(&auth)\nresp.Body.Close()\n\nfmt.Printf(\"Open %s\\nand confirm the code: %s\\n\",\n    auth.VerificationURIComplete, auth.UserCode)\n\ninterval := time.Duration(auth.Interval) * time.Second\npoll := url.Values{\n    \"grant_type\":  {\"urn:ietf:params:oauth:grant-type:device_code\"},\n    \"device_code\": {auth.DeviceCode},\n    \"client_id\":   {clientID},\n}\n\nfor {\n    time.Sleep(interval)\n    r, _ := http.PostForm(meta.TokenEndpoint, poll)\n    var tok struct {\n        AccessToken  string `json:\"access_token\"`\n        RefreshToken string `json:\"refresh_token\"`\n        Error        string `json:\"error\"`\n    }\n    json.NewDecoder(r.Body).Decode(&tok)\n    r.Body.Close()\n\n    switch tok.Error {\n    case \"authorization_pending\":\n        continue\n    case \"slow_down\":\n        interval += 5 * time.Second\n    case \"\":\n        return tok.AccessToken, tok.RefreshToken, nil\n    default:\n        return \"\", \"\", errors.New(tok.Error)\n    }\n}\n```\n\nPoint that at a Keycloak realm with the “OAuth 2.0 Device Authorization Grant” capability turned on (or any other OpenID-certified provider that supports the grant), and you have a working device-flow login.\n\n## If you are shipping a new CLI[#](#if-you-are-shipping-a-new-cli)\n\n- Default to the device flow.\n- Discover endpoints from\n`.well-known/openid-configuration`\n\nso you never hardcode a URL. - Honor\n`interval`\n\nand`slow_down`\n\n. The spec is not a suggestion. - Store the refresh token in the OS keychain, not a JSON file under\n`~/.config`\n\n. - If you really want a loopback path for fast laptop logins, put it behind a\n`--web`\n\nflag. Don’t make it the default.\n\n## Who got it right[#](#who-got-it-right)\n\nA handful of CLIs already default to the device flow:\n\n`gh auth login`\n\nhas used it from the start. It is the cleanest reference implementation I know of in open source.`aws sso login`\n\nruns device flow end to end against IAM Identity Center.`vercel login`\n\n[moved to RFC 8628](https://vercel.com/changelog/new-vercel-cli-login-flow)in September 2025, replacing email-based login and the old`--oob`\n\nflag.- Stripe’s CLI runs its own\n[pairing-code flow](https://bentranter.ca/posts/stripes-cli-login/)that nails the UX but isn’t actually RFC 8628.\n\nAnd then there is the holdout list. Companies pulling in billions a year still ship the loopback\nflow by default, bolted to a paste-the-code fallback for when it inevitably falls over. Google’s\n`gcloud`\n\n. Cloudflare’s `wrangler`\n\n. Anthropic’s own `claude`\n\n. These are not scrappy weekend projects\nwith one maintainer. They are flagship developer tools from companies with effectively infinite\nengineering budget, and they still ship a login flow that breaks the moment you SSH anywhere.\n\nThe escape hatch is the tell. If the “real” flow needs a manual paste-the-code fallback every time the CLI leaves a laptop, the real flow is the fallback. Ship that one as the default and stop pretending.", "url": "https://wpnews.pro/news/cli-authentication-the-right-way", "canonical_source": "https://www.abgeo.dev/blog/cli-authentication-the-right-way/", "published_at": "2026-06-18 06:36:39+00:00", "updated_at": "2026-06-18 06:57:29.859824+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-safety"], "entities": ["Claude Code", "gcloud", "wrangler", "vercel", "Cloudflare", "RFC 8252", "OAuth", "PKCE"], "alternates": {"html": "https://wpnews.pro/news/cli-authentication-the-right-way", "markdown": "https://wpnews.pro/news/cli-authentication-the-right-way.md", "text": "https://wpnews.pro/news/cli-authentication-the-right-way.txt", "jsonld": "https://wpnews.pro/news/cli-authentication-the-right-way.jsonld"}}