브라우저가 없는 SSH, 컨테이너, 원격 환경에서 루프백 OAuth 로그인 흐름이 왜 깨지는지, 그리고 CLI가 RFC 8628 장치 인증 부여를 기본값으로 채택해야 하는 이유를 설명합니다.
SSH로 새 개발 VM에 접속해 세션을 시작하려고 claude를 실행한다. CLI는 쿼리 문자열 깊숙한 곳에 http://localhost:54213/callback이 묻혀 있는 로그인 URL을 출력하고, 원격 박스에서 브라우저를 열려고 시도한 뒤 콜백을 기다리기 시작한다. 이 박스에는 브라우저가 없다. CLI는 실패를 감지하고 Paste code here if prompted를 출력한 뒤 멈춘다. 나는 그 URL을 노트북의 브라우저에 복사해 넣고 로그인한다. 그러자 동의 페이지는 리디렉션 대신 일회용 코드를 건넨다. 나는 그것을 다시 SSH 세션에 붙여 넣는다. 동작은 한다. 동시에 2026 티셔츠를 입은 2009년식이기도 하다.
이 문제는 이미 해결된 문제다. 2019년부터 해결되어 있었다. 그런데 대부분의 CLI는 아직 따라오지 못했다.
이 패턴은 어디에나 있다. gcloud auth login, wrangler login, 예전의 vercel login, 그리고 수많은 벤더 CLI가 모두 같은 춤을 춘다.
127.0.0.1에 HTTP 서버를 바인딩한다. Wrangler는 8976을 고른다. gcloud는 8085를 쓴다. Claude Code는 실행할 때마다 임시 포트를 하나 잡는다.redirect_uri=http://127.0.0.1:<port>/callback을 붙여 시스템 브라우저를 OAuth 인가 엔드포인트로 연다.노트북에서는 대략 5초 만에 끝난다. 네이티브 앱에서의 OAuth에 대한 BCP인 RFC 8252는 앱이 브라우저를 사용할 수 있을 때 이 패턴을 권장하며, 모든 것을 한 기기에서 돌리는 개발자에게는 잘 맞는다. 다만 8252는 호스트에 브라우저가 없을 때 무엇을 해야 하는지는 다루지 않는다. 이 글의 나머지는 정확히 그 경우에 대한 이야기다.
localhost 단계는 눈에 띄지 않는다. CLI는 아무도 읽지 않을 만큼 긴 URL을 출력하지만, 리디렉션 URI는 바로 그 쿼리 문자열 안에 있다.

gcloud auth login의 인가 URL이다. 쿼리 문자열 깊숙한 곳에 CLI가 방금 바인딩한 루프백 콜백 redirect_uri=http%3A%2F%2Flocalhost%3A8085%2F가 숨어 있다.
사용자는 클릭해서 들어가 제공자의 실제 도메인에서 로그인하고 승인한다. 그러면 제공자가 브라우저를 localhost 콜백으로 302 리디렉션한다. CLI의 작은 HTTP 서버는 코드를 읽은 다음 즉시 다시 제공자의 실제 웹사이트에 있는 매끈한 “로그인되었습니다” 페이지로 사용자를 튕겨 보낸다. 최종 리디렉션이 도착하기 전, 주소창에는 localhost URL이 100밀리초 정도 번쩍 나타난다.

리디렉션 도중 포착한 모습이다. 브라우저는 방금 localhost:8085에 도착했고, 인가 코드는 쿼리 문자열에 들어 있으며, 페이지는 아직 로드 중이다. 눈 한 번 깜빡일 사이 CLI의 로컬 서버가 또 다른 리디렉션으로 응답하고 localhost URL은 사라진다.
눈을 깜빡이면 놓친다. 대부분의 사용자는 자기 CLI가 로컬 HTTP 서버를 바인딩했다는 사실조차 알지 못한다. 흐름은 “웹사이트에서 로그인하면 CLI가 알아서 안다”처럼 보이고, 그 환상은 브라우저가 옆에 없는 상태로 CLI를 쓰려는 순간까지 유지된다. 그 환상을 만드는 바로 그 설계 선택이 흐름을 깨뜨리는 선택이기도 하다.
이 모든 것은 한 가지 가정에 기대고 있다. CLI를 실행하는 기계가 브라우저를 실행하는 기계라는 가정이다. 그게 더 이상 참이 아니게 되면, 이 춤은 무너진다.
xdg-open은 오류를 내거나, X 포워딩이 켜져 있으면 볼 수도 없는 원격 박스의 브라우저를 연다. 콜백 포트를 노트북으로 터널링할 수는 있지만, 그러려면 제공자에 등록된 리디렉션 URI가 터널을 통과해 살아남는 포트를 허용해야 한다. 대부분의 설정은 그렇지 않다.xdg-open이나 open조차 들어 있지 않다. -p로 콜백 포트를 뚫을 수는 있지만, 그것도 CLI가 어떤 포트를 잡을지 미리 알고 있을 때만 가능하다. Cloudflare의 CLI에는 바로 이 문제에 막힌 사람들의 이슈가 길게 쌓여 있다./proc/net/tcp를 읽어 리슨 중인 포트를 찾거나, 알려진 포트를 먼저 바인딩하려고 경쟁할 수 있다. PKCE는 코드 교환을 보호한다. 하지만 리디렉션 자체에서 사용자의 인증된 세션을 보호하지는 못한다.이 흐름을 제공하는 모든 CLI는, 이 흐름이 깨질 때를 위한 폴백도 함께 제공한다. gcloud에는 --no-launch-browser가 있다. Wrangler는 멈춰 버리고, 공인된 해결책은 두 번째 터미널에서 직접 localhost URL에 curl을 날리는 것이다. Anthropic의 claude는 “Paste code here if prompted”를 출력하고 기다린다. 이것들은 전부 변장한 수동 장치 흐름이다. CLI가 실제로 사용되는 환경에서는 원래 흐름이 작동하지 않기 때문에 존재한다.
OAuth 2.0 Device Authorization Grant, RFC 8628은 명세가 “입력 제약이 있는 장치”라고 부르는 환경을 위해 2019년에 발표되었다. TV, 콘솔, 그리고 그렇다, CLI를 위한 것이다. 핵심 목적은 토큰을 요청하는 장치와 사용자가 인증하는 장치를 분리하는 데 있다.
프로토콜은 짧다.
CLI는 먼저 제공자의 device_authorization_endpoint에 POST를 보낸다.
POST /oauth/device/code HTTP/1.1
Host: provider.example.com
Content-Type: application/x-www-form-urlencoded
client_id=my-cli&scope=openid+offline_access
제공자는 명세에 나온 그대로 JSON으로 응답한다.
json
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://provider.example.com/device",
"verification_uri_complete": "https://provider.example.com/device?user_code=WDJB-MJHT",
"expires_in": 1800,
"interval": 5
}
CLI는 URL과 짧은 코드(이상적으로는 verification_uri_complete에 대한 QR도 함께)를 출력한 다음, interval초마다 토큰 엔드포인트를 폴링하기 시작한다.
POST /oauth/token HTTP/1.1
Host: provider.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS
&client_id=my-cli
사용자는 원하는 어떤 장치에서든 URL을 연다. 반드시 CLI가 실행 중인 박스일 필요는 없다. 로그인하고, 요청된 스코프와 클라이언트 이름을 확인하고, 짧은 코드가 CLI가 출력한 것과 일치하는지 확인한 뒤 승인한다.
그동안 폴링 응답은 명세의 section 3.5에 정의된 상태들을 순환한다. 기다리는 동안에는 authorization_pending, 제공자가 속도를 늦추라고 하면 slow_down(명세는 명확하다. 간격을 최소 5초 이상 늘려야 한다), 사용자가 거부하면 access_denied, 너무 오래 끌면 expired_token이 온다. 결국 실제 토큰 응답이 도착한다.
이게 프로토콜의 전부다. CLI는 포트를 바인딩하지 않고, 자신이 실행되는 호스트 옆에 브라우저가 있다고 가정하지도 않는다. 같은 로그인 방식이 노트북에서도, 컨테이너에서도, 사람이 승인할 때까지 멈춰 있는 CI 작업에서도 동작한다.
이 폴링이 어떤 독자에게는 구식처럼 보일 수 있고, 내가 이것을 보여주면 가장 먼저 듣는 반응은 대개 “그럼 인증 서버를 두드려 패는 것 아닌가?”이다. 그렇지 않다. 명세의 기본 간격은 5초다. 대부분의 인가는 1분도 안 되어 끝나므로, 전형적인 로그인은 /token에 대략 열 번 안팎의 폴을 날리고 멈춘다. 서버가 속도를 제어한다. slow_down은 바로 제공자가 부하가 있을 때 간격을 늘리도록 만들기 위해 존재하며, 잘 작성된 클라이언트는 반드시 그것을 따라야 한다. 이를 보류 중인 로그인마다 상태를 가진 엔드포인트에 WebSocket이나 SSE 연결을 인가 창 전체 동안 하나씩 열어 두는 것과 비교해 보라. /token에 대한 무상태 폴링이 더 싸고 더 단순하며, 매일 수백만 건의 토큰 갱신을 처리하는 같은 제공자들은 이것 때문에 땀 한 방울 흘리지 않는다.
제공자가 OpenID Connect Discovery를 지원한다면, CLI는 .well-known/openid-configuration에서 device_authorization_endpoint와 token_endpoint를 바로 가져와 URL 하드코딩을 완전히 없앨 수 있다.
장치 흐름에는 이름을 붙일 만한 고유한 공격도 있다. 공격자는 스스로 실제 제공자의 device_authorization_endpoint를 호출해 실제 user_code와 device_code를 받아온 다음, 피해자에게 피싱 메시지를 보낼 수 있다. “IT 팀에서 새 장치 인가가 필요합니다. microsoft.com/devicelogin으로 가서 WDJB-MJHT를 입력하세요.” URL은 진짜다. 코드도 진짜다. 피해자는 그것을 입력하고 실제 자격 증명으로 로그인해 실제 동의 화면을 승인한다. 공격자는 자신이 생성한 device_code로 /token을 계속 폴링하고 있었기 때문에 액세스 토큰을 받게 된다. 러시아계 위협 행위자들은 정확히 이 캠페인을 2024년 8월 이후 M365 테넌트를 상대로 수행했으며, Microsoft Threat Intelligence는 이를 Storm-2372로 추적했고, Volexity는 이를 APT29/Midnight Blizzard로 귀속했다. 여러 대륙의 정부, 국방, NGO 테넌트가 타격을 입었다.
방어는 CLI가 아니라 제공자 쪽에 있다. 짧은 user_code 만료 시간. 클라이언트 이름과 요청 위치를 눈에 띄게 보여 주는 검증 페이지. 입력 시도에 대한 속도 제한. verification_uri_complete를 노출하지 않아 피해자가 링크를 클릭하는 대신 코드를 직접 입력하게 만들기. 가치가 높은 테넌트라면 진짜 해법은, 장치 코드 흐름이 알려진 네트워크나 장치에서 오는 경우가 아니면 차단하는 조건부 접근 정책이다. CLI의 일은 그저 명세를 준수하고 지름길을 발명하지 않는 것이다.
그 어느 것도 루프백으로 돌아가야 할 이유는 아니다. 장치 흐름은 로컬 공격 표면을 사회공학적 공격 표면과 맞바꾼다. 모든 인증 흐름이 그런 식의 절충을 한다. 더 많은 환경에서 동작하는 흐름을 제공하고, 완화책을 제공하는 공급자를 고르는 것이 올바른 선택이다.
전체는 Go 30줄 정도면 들어간다. 프레임워크도 SDK도 없이, 그냥 net/http만 쓰면 된다.
go
form := url.Values{"client_id": {clientID}, "scope": {"openid offline_access"}}
resp, _ := http.PostForm(meta.DeviceAuthorizationEndpoint, form)
var auth struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURIComplete string `json:"verification_uri_complete"`
Interval int `json:"interval"`
}
json.NewDecoder(resp.Body).Decode(&auth)
resp.Body.Close()
fmt.Printf("Open %s\nand confirm the code: %s\n",
auth.VerificationURIComplete, auth.UserCode)
interval := time.Duration(auth.Interval) * time.Second
poll := url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {auth.DeviceCode},
"client_id": {clientID},
}
for {
time.Sleep(interval)
r, _ := http.PostForm(meta.TokenEndpoint, poll)
var tok struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Error string `json:"error"`
}
json.NewDecoder(r.Body).Decode(&tok)
r.Body.Close()
switch tok.Error {
case "authorization_pending":
continue
case "slow_down":
interval += 5 * time.Second
case "":
return tok.AccessToken, tok.RefreshToken, nil
default:
return "", "", errors.New(tok.Error)
}
}
이 코드를 “OAuth 2.0 Device Authorization Grant” 기능이 켜진 Keycloak realm(또는 이 부여 방식을 지원하는 다른 OpenID 인증 제공자)에 연결하면, 동작하는 장치 흐름 로그인을 얻게 된다.
.well-known/openid-configuration에서 엔드포인트를 찾아 URL을 절대 하드코딩하지 말라.interval과 slow_down을 지켜라. 명세는 제안 사항이 아니다.~/.config 아래 JSON 파일이 아니라 OS 키체인에 저장하라.--web 플래그 뒤에 숨겨라. 기본값으로 만들지 말라.이미 장치 흐름을 기본값으로 쓰는 CLI가 몇 가지 있다.
gh auth login은 처음부터 이것을 사용해 왔다. 내가 아는 오픈 소스에서 가장 깔끔한 참고 구현이다.aws sso login은 IAM Identity Center를 상대로 장치 흐름을 처음부터 끝까지 수행한다.vercel login은 2025년 9월에 RFC 8628로 이동해 이메일 기반 로그인과 예전 --oob 플래그를 대체했다.그리고 아직 버티는 목록이 있다. 연간 수십억 달러를 벌어들이는 회사들조차 여전히 루프백 흐름을 기본값으로 내보내고, 그것이 결국 넘어질 때를 대비해 코드를 붙여 넣는 폴백을 덧대어 둔다. Google의 gcloud. Cloudflare의 wrangler. Anthropic 자신의 claude. 이것들은 유지보수자 한 명이 끌고 가는 주말 프로젝트가 아니다. 사실상 무한한 엔지니어링 예산을 가진 회사들의 대표 개발자 도구인데도, 어디든 SSH로 들어가는 순간 깨지는 로그인 흐름을 여전히 내보내고 있다.
탈출구가 모든 것을 말해 준다. CLI가 노트북을 벗어날 때마다 “진짜” 흐름에 수동 코드 붙여 넣기 폴백이 필요하다면, 진짜 폴백은 그 흐름 쪽이다. 그쪽을 기본값으로 내보내고, 아닌 척하는 일을 그만두자.