Skip to content
Published on

고급 OAuth 플로우 — CIBA, Device Flow, DPoP가 필요한 순간

Authors

들어가며

OAuth의 표준 시나리오는 명확합니다. 사용자가 브라우저로 서비스에 접근하고, 인가 서버로 리다이렉트되어 로그인하고, 다시 서비스로 돌아옵니다. Authorization Code Flow + PKCE는 이 시나리오에서 잘 작동하며, OAuth 2.1 draft가 이를 사실상 유일한 사용자 플로우로 정리하고 있습니다.

문제는 브라우저 리다이렉트가 불가능한 상황이 생각보다 많다는 것입니다. 스마트 TV에는 키보드가 없고, CLI 도구에는 브라우저가 없을 수 있고, 콜센터 상담원은 고객의 디바이스를 만질 수 없습니다. 그리고 2026년 현재 가장 뜨거운 사례 — AI 에이전트가 사용자를 대신해 API에 접근해야 하는데, 에이전트 프로세스 안에서 리다이렉트를 받을 방법이 없습니다. MCP(Model Context Protocol) 생태계에서 OAuth가 표준 인가 메커니즘으로 자리 잡고, Keycloak 26.6이 OAuth Client ID Metadata Document(CIMD)를 실험 지원하며 MCP authorization server 역할을 할 수 있게 되면서, "브라우저 없는 디바이스의 인증" 문제는 다시 한번 중심 의제가 되었습니다.

이 글에서는 Device Authorization Grant(RFC 8628), CIBA, DPoP(RFC 9449) 세 가지 메커니즘을 와이어 레벨에서 살펴보고, Keycloak 설정과 보안 고려사항, 선택 가이드를 정리합니다.

표준 리다이렉트가 안 되는 상황들

먼저 문제 공간을 정리합니다. 리다이렉트 기반 플로우가 막히는 전형적인 상황은 다음과 같습니다.

상황제약적합한 메커니즘
스마트 TV, 콘솔, IoT입력 수단 빈약, 브라우저 없거나 불편Device Flow
CLI 도구, 데몬에서의 사용자 인증로컬 브라우저 유무 불확실Device Flow (또는 loopback 리다이렉트)
콜센터 상담원이 고객 인증 필요인증 주체와 요청 주체가 다른 기기CIBA
POS/키오스크에서 고객 승인고객 폰으로 승인해야 함CIBA
AI 에이전트의 대리 접근에이전트에 UI 없음Device Flow + Token Exchange
토큰 탈취 방어 강화bearer 토큰의 한계DPoP (플로우가 아니라 토큰 바인딩)

주의할 점: DPoP는 인증 플로우가 아니라 토큰 구속 메커니즘입니다. 어떤 플로우와도 조합할 수 있으며, 셋을 같은 층위로 보면 안 됩니다.

Device Authorization Grant (RFC 8628)

RFC 8628은 "입력이 불편한 디바이스"와 "사용자의 스마트폰/PC 브라우저"를 분리하는 플로우입니다. TV에서 코드를 보여주고, 사용자는 폰에서 그 코드를 입력해 승인합니다.

+----------+                         +---------------+
|  TV/CLI  |                         |  인가 서버     |
| (디바이스)|                         |               |
+----+-----+                         +-------+-------+
     |  (1) POST /device_authorization      |
     | ------------------------------------>|
     |  (2) device_code + user_code +       |
     |      verification_uri                |
     | <------------------------------------|
     |                                      |
     |  (3) 화면에 표시:                     |
     |  "example.com/device 에서             |
     |   코드 WDJB-MJHT 입력"                |
     |                                      |
     |        +--------+  (4) 폰 브라우저로  |
     |        | 사용자  | ----------------->|
     |        | 스마트폰| 코드 입력+로그인+동의|
     |        +--------+                    |
     |                                      |
     |  (5) 그동안 디바이스는 폴링            |
     |  POST /token (device_code)           |
     | ------------------------------------>|
     |  authorization_pending ...           |
     | <------------------------------------|
     |  (6) 사용자 승인 완료 후               |
     |  access_token 발급                   |
     | <------------------------------------|
     v                                      v

HTTP로 보는 전체 과정

1단계, 디바이스가 device authorization 엔드포인트를 호출합니다.

POST /realms/prod/protocol/openid-connect/auth/device HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded

client_id=smart-tv-app&scope=openid%20profile

응답입니다.

{
  "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
  "user_code": "WDJB-MJHT",
  "verification_uri": "https://idp.example.com/realms/prod/device",
  "verification_uri_complete": "https://idp.example.com/realms/prod/device?user_code=WDJB-MJHT",
  "expires_in": 600,
  "interval": 5
}

디바이스는 user_code와 verification_uri를 화면에 표시합니다(QR 코드로 verification_uri_complete를 보여주는 것이 UX상 일반적입니다). 그리고 interval 초 간격으로 토큰 엔드포인트를 폴링합니다.

POST /realms/prod/protocol/openid-connect/token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code
&device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS
&client_id=smart-tv-app

사용자가 아직 승인하지 않았다면 다음 응답이 옵니다.

{
  "error": "authorization_pending",
  "error_description": "The authorization request is still pending"
}

폴링이 너무 빠르면 slow_down 오류와 함께 간격을 늘리라는 신호가 옵니다. 사용자가 폰에서 코드를 입력하고 로그인/동의를 마치면, 다음 폴링에서 정상 토큰 응답이 내려옵니다.

Keycloak 설정

Keycloak에서 Device Flow는 클라이언트 단위로 켭니다.

# 클라이언트에 device authorization grant 활성화
kcadm.sh update clients/CLIENT-UUID -r prod \
  -s 'attributes."oauth2.device.authorization.grant.enabled"="true"'

# realm 레벨 코드 수명/폴링 간격 조정
kcadm.sh update realms/prod \
  -s 'attributes."oauth2DeviceCodeLifespan"="600"' \
  -s 'attributes."oauth2DevicePollingInterval"="5"'

CLI 도구라면 public 클라이언트 + PKCE 조합이 기본입니다. user_code 입력 페이지는 Keycloak이 기본 제공하며, 테마로 커스터마이징할 수 있습니다.

CIBA — Decoupled 인증

CIBA(Client Initiated Backchannel Authentication)는 OpenID Foundation의 CIBA Core 사양으로 정의된 decoupled 플로우입니다. Device Flow와 반대 방향입니다. Device Flow는 사용자가 코드를 들고 인가 서버로 "찾아가는" 구조라면, CIBA는 인가 서버가 사용자의 인증 디바이스로 "찾아오는" 구조입니다.

전형적인 시나리오는 콜센터입니다. 상담원이 고객 식별자(전화번호 등)로 인증 요청을 보내면, 고객의 폰에 푸시 알림이 떠서 "상담원에게 계좌 조회를 허용하시겠습니까?"를 묻고, 고객이 승인하면 상담원 시스템이 토큰을 받습니다. POS에서의 고액 결제 승인, 백오피스 작업에 대한 관리자 승인에도 같은 패턴이 쓰입니다.

+-----------+                       +-----------+                +---------+
| 상담원     |                       | 인가 서버  |                | 고객의   |
| 시스템(CC) |                       |           |                | 스마트폰 |
+-----+-----+                       +-----+-----+                +----+----+
      | (1) POST /ext/ciba/auth          |                           |
      |  login_hint=고객식별자             |                           |
      | --------------------------------->|                           |
      | (2) auth_req_id 반환              |                           |
      | <---------------------------------|                           |
      |                                   | (3) 인증 디바이스로 푸시    |
      |                                   | ------------------------->|
      |                                   | (4) 고객이 생체인증으로 승인 |
      |                                   | <-------------------------|
      | (5) poll: POST /token             |                           |
      |  grant_type=ciba, auth_req_id     |                           |
      | --------------------------------->|                           |
      | (6) access_token                  |                           |
      | <---------------------------------|                           |
      v                                   v                           v

백채널 인증 요청

POST /realms/prod/protocol/openid-connect/ext/ciba/auth HTTP/1.1
Host: idp.example.com
Authorization: Basic Y2FsbC1jZW50ZXI6czNjcjN0
Content-Type: application/x-www-form-urlencoded

login_hint=customer-01087654321
&scope=openid%20account%3Aread
&binding_message=CS-4711
&requested_expiry=120

binding_message는 양쪽 화면(상담원과 고객 폰)에 동시에 표시되는 짧은 코드로, 고객이 "지금 뜬 승인 요청이 내가 통화 중인 그 건"임을 확인하는 피싱 방어 장치입니다. 응답은 다음과 같습니다.

{
  "auth_req_id": "1c266114-a1be-4252-8ad1-04986c5b9ac1",
  "expires_in": 120,
  "interval": 5
}

poll, ping, push — 세 가지 토큰 전달 모드

CIBA는 클라이언트가 결과를 받는 방식으로 세 모드를 정의합니다.

모드동작클라이언트 요구사항비고
poll클라이언트가 토큰 엔드포인트를 주기 폴링없음(가장 단순)Device Flow와 유사한 패턴
ping승인 완료 시 AS가 클라이언트 알림 엔드포인트로 통지, 클라이언트가 토큰 엔드포인트 호출콜백 엔드포인트 필요폴링 부하 절감
pushAS가 토큰 자체를 클라이언트 콜백으로 직접 전달콜백 + 강한 보안 요구FAPI-CIBA 프로파일에서는 금지

poll 모드의 토큰 요청은 다음과 같습니다.

POST /realms/prod/protocol/openid-connect/token HTTP/1.1
Host: idp.example.com
Authorization: Basic Y2FsbC1jZW50ZXI6czNjcjN0
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aopenid%3Aparams%3Agrant-type%3Aciba
&auth_req_id=1c266114-a1be-4252-8ad1-04986c5b9ac1

Keycloak에서의 CIBA

Keycloak은 poll과 ping 모드를 지원합니다. CIBA의 핵심 난점은 "사용자의 인증 디바이스로 어떻게 신호를 보내는가"인데, Keycloak은 이를 외부 인증 엔티티에 위임하는 구조를 취합니다. realm의 CIBA 정책에서 백채널 인증 방식을 설정하고, 실제 푸시/승인 처리는 회사의 모바일 앱 + 인증 서버가 HTTP 콜백으로 연동합니다.

# realm CIBA 정책 설정 예
kcadm.sh update realms/prod \
  -s 'attributes."cibaBackchannelTokenDeliveryMode"="poll"' \
  -s 'attributes."cibaExpiresIn"="120"' \
  -s 'attributes."cibaInterval"="5"'

# 클라이언트에 CIBA grant 활성화
kcadm.sh update clients/CLIENT-UUID -r prod \
  -s 'attributes."oidc.ciba.grant.enabled"="true"'

도입 시 가장 많은 공수가 들어가는 부분은 Keycloak 설정이 아니라 인증 디바이스 측 구현(푸시 수신, 승인 UI, 결과 콜백)이라는 점을 미리 계획에 반영해야 합니다.

DPoP (RFC 9449) — 토큰을 키에 묶다

DPoP는 "이 토큰은 특정 키 쌍의 보유자만 쓸 수 있다"를 강제하는 sender-constraining 메커니즘입니다. bearer 토큰은 훔치면 그대로 쓸 수 있지만, DPoP 토큰은 훔쳐도 개인키가 없으면 무용지물입니다.

proof JWT의 구조

클라이언트는 요청마다 DPoP proof라는 JWT를 만들어 DPoP 헤더에 실어 보냅니다. proof의 헤더에는 공개키(jwk)가, 페이로드에는 요청 바인딩 정보가 들어갑니다.

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
    "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
  }
}
{
  "jti": "-BwC3ESc6acc2lTc",
  "htm": "POST",
  "htu": "https://idp.example.com/realms/prod/protocol/openid-connect/token",
  "iat": 1781234567
}
  • jti: proof 고유 ID. 서버가 재사용을 탐지하는 데 사용
  • htm/htu: 이 proof가 유효한 HTTP 메서드와 URI. 다른 엔드포인트로의 재사용 차단
  • iat: 발급 시각. 허용 시간창(보통 수십 초)을 벗어나면 거부

토큰 발급 시 인가 서버는 proof의 공개키 지문(jkt)을 액세스 토큰의 cnf 클레임에 박습니다. 이후 API 호출에서는 두 가지가 함께 갑니다.

GET /api/accounts HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJFUzI1NiIs...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...

리소스 서버의 검증 순서는 (1) proof 서명을 proof 안의 공개키로 검증, (2) 그 공개키의 지문과 토큰 cnf의 jkt 일치 확인, (3) htm/htu/iat/jti 검증, (4) 리소스 서버가 요구한다면 proof의 ath 클레임(액세스 토큰 해시)까지 확인입니다.

Replay 방어의 층위

DPoP의 replay 방어는 여러 겹입니다. htu/htm으로 다른 요청에의 재사용을 막고, iat 시간창으로 오래된 proof를 거르고, jti 추적으로 같은 proof의 재제출을 잡습니다. 추가로 서버가 DPoP-Nonce 헤더로 자체 발급 nonce를 요구할 수 있는데, 이 경우 proof에 서버 nonce가 포함되어야 하므로 사전 생성형 replay가 원천 차단됩니다. nonce 요구 시 서버는 400 응답에 use_dpop_nonce 오류와 함께 새 nonce를 내려주고, 클라이언트는 proof를 다시 만들어 재시도합니다. 클라이언트 SDK가 이 재시도 루프를 처리하는지 반드시 확인하십시오.

Keycloak에서의 DPoP

Keycloak 26.x는 DPoP를 지원하며, 클라이언트 단위로 강제할 수 있습니다.

# 클라이언트에 DPoP 바인딩 강제
kcadm.sh update clients/CLIENT-UUID -r prod \
  -s 'attributes."dpop.bound.access.tokens"="true"'

활성화하면 토큰 엔드포인트가 DPoP proof를 요구하고, 발급된 토큰에 cnf/jkt가 포함됩니다. 게이트웨이/리소스 서버 쪽 검증 로직(특히 프록시의 URL 재작성으로 htu가 불일치하는 문제)을 함께 점검해야 합니다.

플로우 조합과 PAR

세 메커니즘은 배타적이지 않습니다. 실전에서 유용한 조합은 다음과 같습니다.

  • Device Flow + DPoP: CLI 도구가 device flow로 토큰을 받되 DPoP로 바인딩하면, 토큰 파일이 유출되어도 키 파일 없이는 사용 불가입니다.
  • CIBA + FAPI: 금융권 decoupled 승인은 FAPI-CIBA 프로파일을 따릅니다(push 모드 금지, 서명 요청 등 강화 요구사항).
  • PAR + 리다이렉트 플로우: 키오스크처럼 리다이렉트가 가능하지만 요청 무결성이 중요한 경우, PAR(RFC 9126)로 인가 요청을 백채널 등록하고 짧은 request_uri만 프런트로 보냅니다. PAR는 device flow나 CIBA의 백채널 요청과 철학을 공유하는 "요청의 백채널화"입니다.

보안 고려사항

Device code phishing

Device Flow의 최대 약점은 사용자 측 검증 부재입니다. 공격자가 자신의 디바이스에서 플로우를 시작하고, user_code가 담긴 verification_uri_complete 링크를 피해자에게 보내 "보안 확인을 위해 로그인하세요"라고 속이면, 피해자의 승인으로 공격자 디바이스가 토큰을 받습니다. 실제로 device code phishing은 최근 수년간 국가 배후 공격 그룹의 단골 수법이었습니다.

완화책은 다음과 같습니다.

  1. 동의 화면에 디바이스 정보와 경고를 명시("TV 앱에서 시작된 요청입니다. 직접 시작하지 않았다면 거부하세요")
  2. user_code의 짧은 수명과 시도 횟수 제한, 레이트 리미팅
  3. 민감 스코프에는 device flow 발급을 막는 클라이언트 정책
  4. 이상 징후 탐지: 디바이스 요청 IP와 승인 IP의 지리적 불일치 알림
  5. 사용자 교육과 binding 컨텍스트 표시(요청 시각, 위치)

CIBA의 위험 — 승인 피로와 무차별 요청

CIBA는 공격자가 임의 사용자에게 승인 푸시를 보낼 수 있는 구조이므로, MFA 푸시 피로(fatigue) 공격과 같은 패턴이 가능합니다. login_hint로 사용자를 특정할 수 있는 클라이언트를 엄격히 제한하고, binding_message 표시를 의무화하며, 승인 요청 빈도를 사용자 단위로 제한해야 합니다. requested_expiry는 가능한 한 짧게 잡습니다.

DPoP의 한계

DPoP는 토큰 탈취에는 강하지만, 키와 토큰이 함께 있는 디바이스 자체가 침해되면 막을 수 없습니다. 또한 proof 생성 키를 비추출(non-extractable) 저장소(WebCrypto, Secure Enclave, TPM)에 두지 않으면 방어 가치가 급감합니다. 시계 오차(iat 검증 실패)와 프록시의 URL 변형(htu 불일치)은 가장 흔한 운영 장애 포인트입니다.

실전: bash로 만드는 device flow 클라이언트

개념을 굳히기 위해, curl과 jq만으로 동작하는 최소 device flow 클라이언트를 만들어 보겠습니다. CI 스크립트나 사내 CLI 도구의 골격으로 그대로 쓸 수 있습니다.

#!/usr/bin/env bash
set -euo pipefail

IDP="https://idp.example.com/realms/prod/protocol/openid-connect"
CLIENT_ID="internal-cli"

# 1. device authorization 요청
resp=$(curl -s -X POST "$IDP/auth/device" \
  -d "client_id=$CLIENT_ID" -d "scope=openid profile")

device_code=$(echo "$resp" | jq -r .device_code)
user_code=$(echo "$resp" | jq -r .user_code)
verification_uri=$(echo "$resp" | jq -r .verification_uri)
interval=$(echo "$resp" | jq -r .interval)

echo "브라우저에서 $verification_uri 접속 후"
echo "코드 [$user_code] 를 입력해 주세요."

# 2. 승인될 때까지 폴링
while true; do
  sleep "$interval"
  token_resp=$(curl -s -X POST "$IDP/token" \
    -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
    -d "device_code=$device_code" \
    -d "client_id=$CLIENT_ID")

  error=$(echo "$token_resp" | jq -r '.error // empty')
  case "$error" in
    "") break ;;                                # 성공
    authorization_pending) continue ;;          # 대기 중
    slow_down) interval=$((interval + 5)) ;;    # 간격 확대
    expired_token)
      echo "코드가 만료되었습니다. 다시 시도하세요." >&2; exit 1 ;;
    *) echo "오류: $error" >&2; exit 1 ;;
  esac
done

access_token=$(echo "$token_resp" | jq -r .access_token)
echo "토큰 발급 완료 (앞 20자): $(echo "$access_token" | cut -c1-20)..."

운영 코드라면 여기에 세 가지를 더해야 합니다. 첫째, 토큰을 디스크에 저장할 때는 권한 600의 사용자 전용 디렉터리(또는 OS 키체인)를 사용합니다. 둘째, refresh token 갱신 루프와 만료 처리를 넣습니다. 셋째, 가능하다면 DPoP 키 쌍을 생성해 발급 단계부터 바인딩합니다. expired_token 처리처럼 사용자가 자리를 비웠을 때의 UX(재시도 안내)도 빼놓기 쉬운 부분입니다.

선택 가이드

상황별 의사결정을 표로 정리합니다.

질문예라면
사용자가 같은 기기의 브라우저로 로그인 가능한가Authorization Code + PKCE (필요시 PAR)
기기에 입력/브라우저가 없고, 사용자가 다른 기기를 쓸 수 있는가Device Flow
요청을 시작하는 주체와 승인하는 사용자가 서로 다른 채널에 있는가CIBA
사용자 개입 없이 시스템 권한만 필요한가Client Credentials
다른 서비스 호출을 위해 사용자 토큰을 변환해야 하는가Token Exchange (RFC 8693)
토큰 탈취 시 피해를 차단해야 하는가위 플로우 + DPoP (또는 mTLS)

2026년의 맥락 — AI 에이전트와 CLI의 device flow 르네상스

Device Flow는 2012년경 TV 앱을 위해 설계된 오래된 메커니즘이지만, 2026년 현재 가장 빠르게 채택이 늘고 있는 플로우이기도 합니다. 이유는 명확합니다.

  • CLI/개발자 도구의 표준 패턴화: GitHub CLI를 비롯한 주요 개발자 도구가 device flow를 기본 로그인으로 채택하면서, "터미널에 코드 띄우고 브라우저에서 승인"이 개발자에게 익숙한 UX가 되었습니다.
  • AI 에이전트의 인간 승인 루프: 에이전트가 사용자 대신 API에 접근하려면 어딘가에서 사람의 동의가 필요합니다. 에이전트 프로세스에는 브라우저가 없으므로, device flow의 "코드 전달 + 외부 승인" 구조가 자연스러운 합의 지점이 됩니다. MCP 생태계에서 OAuth 인가 서버(예: CIMD를 지원하는 Keycloak 26.6)와 에이전트 클라이언트의 조합이 이 패턴 위에 구축되고 있습니다.
  • 승인 이후의 위임: 에이전트가 받은 사용자 토큰을 개별 도구/API용으로 좁혀 쓰려면 Token Exchange와 결합합니다. "device flow로 최초 동의 → exchange로 작업별 최소 권한 토큰"이 에이전트 인증의 권장 골격입니다.
  • 비인간 신원(non-human identity)의 폭증: 에이전트와 워크로드가 사람보다 많아지는 환경에서, 사람의 개입 지점을 명시적으로 설계하는 플로우(device flow, CIBA)와 키 기반 구속(DPoP)의 조합은 Zero Trust 원칙의 실무 번역이라고 할 수 있습니다.

오래된 스펙이 새로운 문제의 답이 되는 것은 표준의 세계에서 드문 일이 아닙니다. 다만 device code phishing처럼 채택 확대와 함께 공격도 늘고 있으므로, 위의 완화책을 기본값으로 깔고 시작해야 합니다.

마치며

요약합니다.

  • 리다이렉트가 안 되는 상황은 예외가 아니라 일상입니다. Device Flow(같은 사용자, 다른 기기), CIBA(다른 주체, 다른 채널)로 문제를 분류하십시오.
  • Device Flow는 단순하지만 phishing 방어를 설계에 포함해야 합니다. user_code 수명, 레이트 리밋, 동의 화면 경고는 기본입니다.
  • CIBA는 poll 모드부터 시작하고, 인증 디바이스 측 구현 공수를 과소평가하지 마십시오. binding_message는 항상 사용합니다.
  • DPoP는 플로우가 아니라 토큰 구속입니다. 어떤 플로우에든 더할 수 있으며, 공개 클라이언트의 토큰 탈취 방어로 가장 비용 효율이 좋습니다.
  • Keycloak은 세 메커니즘을 모두 지원하므로, 클라이언트 정책으로 "민감 클라이언트에는 DPoP 강제" 같은 가드레일을 선언적으로 깔 수 있습니다.
  • AI 에이전트 시대의 인증 골격은 "device flow로 사람의 동의 확보 → token exchange로 최소 권한 위임 → DPoP로 구속"입니다.

플로우는 도구일 뿐입니다. 핵심은 "누가, 어디서, 무엇으로 승인하는가"를 명확히 한 뒤 그에 맞는 표준을 고르는 것입니다.

참고 자료