# CLI 인증, 올바른 방식

> Clean Markdown view of GeekNews topic #30648. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=30648](https://news.hada.io/topic?id=30648)
- GeekNews Markdown: [https://news.hada.io/topic/30648.md](https://news.hada.io/topic/30648.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-06-20T00:11:15+09:00
- Updated: 2026-06-20T00:11:15+09:00
- Original source: [abgeo.dev](https://www.abgeo.dev/blog/cli-authentication-the-right-way/)
- Points: 1
- Comments: 1

## Topic Body

- 많은 CLI는 노트북의 로컬 브라우저에서는 빠르게 끝나는 **localhost OAuth 리다이렉트**를 기본값으로 쓰지만, SSH·컨테이너·WSL 같은 개발 환경에서는 같은 가정이 깨져 로그인 흐름이 멈춤
- 현재 방식은 CLI가 `127.0.0.1`에 임시 HTTP 서버를 열고 브라우저를 인증 URL로 보낸 뒤, 인증 제공자가 authorization code를 로컬 callback으로 돌려주는 구조임
- 2019년 표준화된 **RFC 8628 Device Authorization Grant**는 토큰을 요청하는 CLI와 사용자가 인증하는 브라우저 장치를 분리해, 포트 바인딩이나 로컬 브라우저 의존을 없앰
- Device flow는 `device_code`, `user_code`, `verification_uri`, `interval`을 받아 `/token`을 주기적으로 폴링하고, `authorization_pending`, `slow_down`, `access_denied`, `expired_token` 같은 표준 상태를 처리함
- 새 CLI라면 device flow를 기본값으로 두고 `.well-known/openid-configuration`으로 엔드포인트를 발견하며, refresh token은 `~/.config`의 JSON 파일이 아니라 **OS keychain**에 저장해야 함

---

### localhost 리다이렉트가 전제하는 것
- 흔한 CLI 로그인은 **로컬 HTTP 서버**와 시스템 브라우저가 같은 머신에 있다는 가정 위에서 동작함
  - CLI가 `127.0.0.1`의 특정 포트에 HTTP 서버를 바인딩함
  - 시스템 브라우저를 OAuth authorization endpoint로 열고 `redirect_uri=http://127.0.0.1:&lt;port&gt;/callback`을 포함함
  - 사용자가 로그인하면 인증 제공자가 authorization code를 loopback URL로 `302` 리다이렉트함
  - CLI의 작은 HTTP 서버가 code를 읽고 token endpoint에서 토큰으로 교환함
  - 대부분 **PKCE**가 붙고, 이후 “이 탭을 닫아도 됩니다” 페이지가 표시됨
- `gcloud auth login`, `wrangler login`, 이전 `vercel login`과 여러 vendor CLI가 이 방식을 사용함
  - Wrangler는 `8976` 포트를 사용함
  - gcloud는 `8085`를 사용함
  - Claude Code는 실행할 때마다 임시 포트를 잡음
- [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252)는 native app에서 브라우저가 있는 경우 이 패턴을 권장하지만, 호스트에 브라우저가 없을 때의 처리는 다루지 않음

### 사용자가 localhost 단계를 잘 못 보는 이유
- localhost callback은 매우 짧게 지나가므로 대부분 사용자가 보지 못함
- CLI가 출력하는 URL은 길고, 그 안에 redirect URI가 query string으로 들어 있음
- 사용자는 인증 제공자의 실제 도메인에서 로그인하고 승인함
- 인증 제공자는 브라우저를 localhost callback으로 보낸 뒤 CLI가 code를 읽게 하고, 다시 polished “signed in” 페이지로 이동시킴
- 겉으로는 “웹사이트에서 로그인했더니 CLI가 인증됐다”처럼 보이지만, 실제로는 **로컬 HTTP 서버와 브라우저의 공존**이 흐름을 지탱함

### SSH·컨테이너·WSL에서 깨지는 지점
- 전체 흐름은 **CLI가 실행되는 머신과 브라우저가 실행되는 머신이 같다**는 가정에 의존함
- SSH 세션에서는 원격 호스트에 브라우저가 없고, `xdg-open`이 실패하거나 X forwarding 환경에서 보이지 않는 원격 브라우저를 열 수 있음
  - callback 포트를 노트북으로 터널링할 수는 있지만, 인증 제공자에 등록된 redirect URI가 터널을 통과한 포트를 허용해야 함
- 컨테이너에는 브라우저가 없고, 많은 이미지에는 `xdg-open`이나 `open`도 없음
  - `-p`로 callback 포트를 노출할 수 있지만 CLI가 어떤 포트를 잡을지 알아야 함
  - Cloudflare CLI에는 이 문제로 막힌 사용자의 [이슈](https://github.com/cloudflare/workers-sdk/issues/862)가 이어짐
- WSL에서는 브라우저가 Windows에서 열리고 loopback server는 Linux에서 실행됨
  - WSL2의 포트 포워딩은 대부분 동작하지만 항상 그렇지는 않음
- shared box에서는 같은 머신의 다른 프로세스가 `/proc/net/tcp`로 listening port를 찾거나, 알려진 포트를 먼저 바인딩하려고 경쟁할 수 있음
  - PKCE는 code exchange를 보호하지만 redirect 자체의 authenticated session을 보호하지 않음

### fallback이 이미 드러내는 설계 문제
- loopback 흐름을 기본으로 제공하는 CLI들은 깨질 때를 위한 fallback도 함께 제공함
- gcloud에는 `--no-launch-browser`가 있음
- Wrangler는 멈추며, 수용된 workaround는 두 번째 터미널에서 localhost URL을 직접 curl하는 방식임
- Anthropic의 `claude`는 “Paste code here if prompted”를 출력하고 기다림
- 이런 fallback은 사실상 **수동 device flow**이며, CLI가 실제로 쓰이는 환경에서 기본 흐름이 동작하지 않기 때문에 존재함

### RFC 8628 Device Authorization Grant
- [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)은 2019년에 “input-constrained devices”를 위해 나온 OAuth 2.0 Device Authorization Grant임
  - TV, 콘솔, CLI가 대상에 포함됨
  - 토큰을 요청하는 장치와 사용자가 인증하는 장치를 분리하는 것이 핵심임
- CLI는 인증 제공자의 `device_authorization_endpoint`에 POST함
  - 예시 요청은 `client_id=my-cli&scope=openid+offline_access`를 전송함
- 인증 제공자는 다음 값을 포함한 JSON을 반환함
  - `device_code`
  - `user_code`
  - `verification_uri`
  - `verification_uri_complete`
  - `expires_in`
  - `interval`
- CLI는 URL과 짧은 코드를 출력하고, 가능하면 `verification_uri_complete`에 대한 QR도 보여줌
- 사용자는 원하는 장치에서 URL을 열고 로그인한 뒤, 요청 scope와 client name을 보고 CLI에 표시된 짧은 코드와 일치하는지 확인한 후 승인함

### 폴링과 표준 상태 처리
- CLI는 token endpoint를 `interval`초마다 폴링함
- grant type은 `urn:ietf:params:oauth:grant-type:device_code`를 사용함
- [RFC 8628 section 3.5](https://datatracker.ietf.org/doc/html/rfc8628#section-3.5)는 다음 상태를 정의함
  - `authorization_pending`: 사용자의 승인을 기다리는 상태
  - `slow_down`: 인증 제공자가 폴링 간격을 늦추라고 요청한 상태이며, 명세는 interval을 최소 5초 늘리라고 명시함
  - `access_denied`: 사용자가 거부한 상태
  - `expired_token`: 너무 오래 기다려 토큰이 만료된 상태
- device flow에서는 CLI가 포트를 바인딩하지 않고, 실행 호스트에 브라우저가 있다고 가정하지 않음
- 같은 로그인 방식이 노트북, 컨테이너, 사람의 승인을 기다리는 CI job에서 동작함

### 폴링 비용과 엔드포인트 발견
- 기본 폴링 interval은 5초임
- 대부분 인증은 1분 이내에 끝나므로 일반적인 로그인은 `/token`에 약 10번 정도 폴링하고 멈춤
- 서버는 `slow_down`으로 interval을 늘릴 수 있고, 잘 작성된 client는 이를 따라야 함
- pending login마다 stateful endpoint에 WebSocket이나 SSE 연결을 유지하는 방식과 비교하면, `/token`에 대한 stateless polling이 더 단순하고 저렴함
- 인증 제공자가 [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html)를 지원하면 CLI는 `.well-known/openid-configuration`에서 `device_authorization_endpoint`와 `token_endpoint`를 가져와 URL 하드코딩을 없앨 수 있음

### device flow의 피싱 위험
- device flow에는 공격자가 실제 인증 제공자의 `device_authorization_endpoint`를 호출해 `user_code`와 `device_code`를 받은 뒤 피해자에게 입력을 유도하는 공격이 있음
- 피해자는 실제 URL에서 실제 코드로 로그인하고 실제 consent screen을 승인할 수 있음
- 공격자는 자신이 생성한 `device_code`로 `/token`을 폴링하다가 access token을 받음
- 러시아 threat actor는 2024년 8월 이후 M365 tenant를 상대로 이 캠페인을 수행함
  - Microsoft Threat Intelligence는 이를 [Storm-2372](https://www.microsoft.com/en-us/security/blog/2025/02/13/storm-2372-conducts-device-code-phishing-campaign/)로 추적함
  - Volexity는 [APT29/Midnight Blizzard](https://www.volexity.com/blog/2025/02/13/multiple-russian-threat-actors-targeting-microsoft-device-code-authentication/)로 attribution함
  - 정부, 방위, NGO tenant가 여러 대륙에서 영향을 받음

### 피싱 방어는 인증 제공자 책임
- 피싱 방어는 CLI가 아니라 **인증 제공자** 쪽에서 이뤄져야 함
- 필요한 완화책은 다음과 같음
  - 짧은 `user_code` 만료 시간
  - verification page에서 client name과 요청 위치를 눈에 띄게 표시
  - code 입력 시도에 대한 rate limiting
  - `verification_uri_complete`를 노출하지 않아 피해자가 링크를 클릭하는 대신 코드를 직접 입력하게 함
  - 고가치 tenant에서는 known network나 device가 아니면 device code flow를 막는 conditional access policy
- CLI의 역할은 명세를 따르고 shortcut을 만들지 않는 것임
- device flow는 local attack surface를 social attack surface로 바꾸지만, 더 많은 환경에서 동작하는 흐름을 제공하고 인증 제공자의 mitigation을 활용하는 쪽이 더 적절함

### Go 구현 예시의 핵심 흐름
- 전체 구현은 Go에서 `net/http`만으로 약 30줄에 들어감
- 구현 흐름은 다음과 같음
  - `client_id`와 `scope`를 담아 `DeviceAuthorizationEndpoint`에 `http.PostForm` 호출
  - 응답 JSON에서 `DeviceCode`, `UserCode`, `VerificationURIComplete`, `Interval`을 디코딩
  - 사용자에게 `VerificationURIComplete`와 `UserCode`를 출력
  - `TokenEndpoint`에 `device_code`, `client_id`, device grant type을 넣어 반복 POST
  - `authorization_pending`이면 계속 대기
  - `slow_down`이면 interval을 5초 늘림
  - error가 없으면 `access_token`과 `refresh_token`을 반환
  - 다른 error는 실패로 처리
- Keycloak realm에서 “OAuth 2.0 Device Authorization Grant” capability를 켜거나, grant를 지원하는 OpenID-certified provider를 쓰면 device-flow login이 동작함

### 새 CLI의 기본값으로 삼을 방식
- 기본값은 **device flow**로 설정해야 함
- `.well-known/openid-configuration`에서 엔드포인트를 발견해 URL을 하드코딩하지 않아야 함
- `interval`과 `slow_down`을 반드시 지켜야 함
- refresh token은 `~/.config` 아래 JSON 파일이 아니라 OS keychain에 저장해야 함
- 빠른 노트북 로그인을 위해 loopback path를 제공하고 싶다면 `--web` flag 뒤에 두고 기본값으로 만들지 않아야 함

### 이미 옮겨간 CLI와 남은 도구들
- device flow를 기본값으로 쓰는 CLI들이 있음
  - `gh auth login`은 처음부터 device flow를 사용했으며, open source에서 가장 깔끔한 reference implementation으로 평가됨
  - `aws sso login`은 IAM Identity Center를 상대로 device flow를 end-to-end로 실행함
  - `vercel login`은 2025년 9월 [RFC 8628로 이동](https://vercel.com/changelog/new-vercel-cli-login-flow)하며 email-based login과 이전 `--oob` flag를 대체함
  - Stripe CLI는 RFC 8628 자체는 아니지만 UX를 잘 구현한 [pairing-code flow](https://bentranter.ca/posts/stripes-cli-login/)를 사용함
- 여전히 loopback flow를 기본으로 두고 paste-the-code fallback을 붙인 도구들도 있음
  - Google `gcloud`
  - Cloudflare `wrangler`
  - Anthropic `claude`
- CLI가 노트북을 벗어날 때마다 수동 paste-the-code fallback이 필요하다면, 그 fallback을 기본 흐름으로 제공하는 편이 맞음

## Comments



### Comment 59991

- Author: neo
- Created: 2026-06-20T00:11:17+09:00
- Points: 1

###### [Lobste.rs 의견들](https://lobste.rs/s/nqv7yo/cli_authentication_right_way) 
- 표현은 좀 대충이지만 흥미로움. **기기 코드/링크를 1분마다 교체**하면 피싱에 악용되는 것도 줄일 수 있을 듯함  
  한 번 사용된 뒤에는 회전을 멈추고 해당 세션을 **IP나 브라우저**에 묶어두면 됨
  - 이 방식이 글에서 말하는 것만큼 크게 도움이 되지는 않음. 사용자가 들어오면 흐름을 시작하고 곧바로 정상 제공자로 리다이렉트하는 **피싱 랜딩 페이지**를 만드는 건 꽤 쉬움  
    Microsoft처럼 사용자가 코드를 직접 입력하게 하는 제공자라면, 랜딩 페이지가 안내문을 띄우고 코드를 클립보드에 복사해 피싱에 더 쉽게 걸리게 만들 수도 있음

- 좋은 글이고, 모두가 **RFC 8628** 쪽으로 옮겨가야 한다는 데 동의함  
  원격 개발 머신에서 CLI OAuth 절차를 너무 자주 겪다 보니, `xdg-open`을 가로채고 포트를 자동 포워딩해서 나쁜 사용자 경험을 덮어주는 개인 도구를 만들었음: https://github.com/phinze/bankshot

- 흥미로움. 최근에 마침 “옛” 인증 방식인 **RFC 8252**를 구현했는데, “새” 방식인 RFC 8268은 모르고 있었음  
  내 주된 사용 사례가 Google 서버 인증이었기 때문에 지식 공백이 생긴 듯함. 내가 RFC 8268 흐름이라고 생각하는 문서에는 [다음처럼 명시돼 있음](https://developers.google.com/identity/protocols/oauth2/limited-input-device)  
  > # Alternatives  
  >  
  > If you are writing an app for a platform such as Android, iOS, macOS, Linux, or Windows (including the Universal Windows Platform), that has access to the browser and full input capabilities, use the [OAuth 2.0 flow for mobile and desktop applications](https://developers.google.com/identity/protocols/oauth2/native-app). **(You should use that flow even if your app is a command-line tool without a graphical interface.)**  
  그래서 그대로 **RFC 8252 흐름**만 읽고 구현했음. 내 도구는 CLI가 맞지만, 사용 사례가 로컬 전용이라 SSH나 컨테이너 환경은 고려하지 않았음  
  게다가 RFC 8268 흐름에서는 Google이 [제한된 OAuth 2.0 범위](https://developers.google.com/identity/protocols/oauth2/limited-input-device#allowedscopes)만 허용하므로, 일부 애플리케이션에는 결정적인 제약이 될 수 있음
  - 사소한 정정: 원문 번호를 다시 확인해 보니 **RFC 8628**임  
    Google의 범위 제약은 [OIDC가 복잡하게 고개를 드는 부분](https://openid.net/specs/openid-connect-core-1_0.html#Introduction)임. 이상적으로는 Google이 접근 토큰에 뭉개 넣는 대신 ID 토큰을 돌려줘야 하지만, 그건 Google의 OAuth 설정 문제지 8628 자체의 특성은 아님  
    OAuth의 끝없는 복잡함은 여기서 옴. 표준은 권한 부여 체계를 **어떻게 만들고 전달할지**의 틀을 잘 정의하지만, 그것이 **무엇이어야 하는지**에는 의도적으로 침묵함. “대부분”의 제공자가 동의하는 공통 HTTP 엔드포인트 묶음을 얻는 데도 OIDC 발명과 여러 해가 필요했음

- 또 다른 해킹은 서버의 **xdg-open 호출을 노트북으로 포워딩**하는 것임. 개인 인프라용으로 그걸 해주는 작은 도구를 만들었음: https://github.com/zimbatm/subportal/

- 두 접근을 결합하면 안 되나? `localhost` URL로 리다이렉트하고 `hello`를 되돌려 보내게 한 뒤, 클라이언트가 `hello`를 못 받으면 CLI에 URL을 출력하는 방식임  
  동시에 서버가 보낸 `hello`의 응답을 받지 못하면 브라우저에 코드를 띄우고 “로그인을 시도 중인지 확인하라” 같은 메시지를 보여주면 됨. Google처럼 휴대폰에서 선택할 숫자를 표시하는 식으로 더 쉽게 만들 수도 있음  
  ```text  
  cli -> server/auth?r=localhost&fallback_choices=10,20,30  
  server -> localhost/hello
  
  Case 1: hello request received, go to redirect URI on localhost  
  Case 2: server has not received a hello reply, client has not received a hello request
  - CLI displays a/the webpage url and prompts for selecting a fallback_choice
  - Webpage displays a number say `20` from choices
    - Warn in the webpage not to share this code
  - User enters/selects it on the CLI
    - solves the token copy/paste problem if choices  
  ```  
  장점은 2번 경우에도 사람들이 링크 클릭은 쉽게 하지만 **OTP/코드 공유**는 상대적으로 덜 한다는 점이고, 공격자가 해킹 중에 계속 사회공학적으로 개입해야 한다는 점임

- 로컬 머신에서 잘 동작할 때는 상호작용이 필요 없으니, 기본값은 **브라우저 기반 흐름**이었으면 좋겠음
  - 이 흐름도 잘 동작하는 경우에는 브라우저 기반으로 작동함. 다만 실패했을 때 **더 나은 대체 경로**가 있음
