OAuth를 문서로만 읽었을 때는 “code를 토큰으로 바꾼다”는 흐름까지는 알겠는데, state가 필요한지, 왜 2.1이 implicit을 없앴는지, PKCE가 정확히 무엇을 막는지는 잘 남지 않았습니다. 스펙을 여러 번 읽어도 매번 복붙하던 그 파라미터들이 무슨 일을 하는지 손에 잡히지 않았습니다.

그래서 이번에는 읽는 대신 직접 만들어보기로 했습니다. 먼저 취약점이 있는 최소한의 서버를 만들고, 그 취약점을 직접 익스플로잇한 뒤, 보완하는 순서로 진행했습니다. OAuth 2.1이 2.0에서 바꾼 항목들은 대부분 “과거에 실제로 악용됐기 때문에 생긴 규칙”이라서, 직접 익스플로잇해보는 순서가 곧 2.1을 이해하는 순서가 된다고 생각했습니다.

Go로 네 개의 서버를 로컬에 띄웠습니다.

:9002앱 (클라이언트):9000인가서버:9001리소스서버:9003공격자 사이트code → 토큰Bearer 토큰으로 데이터 요청공격 실습 대상
로컬에 띄운 네 개의 서버. 사용자가 브라우저로 보는 것은 앱(:9002)과 인가서버(:9000)의 화면뿐입니다.

각 방어는 껐다 켤 수 있게 만들어서, 같은 공격을 방어 전과 후에 한 번씩 돌려볼 수 있게 했습니다.

만들기 전에 먼저 정리해야 할 것이 있었습니다. 이 네 서버가 각각 무슨 역할이고, 그중 누가 무엇을 볼 수 있는가 하는 점입니다.

./lab off state    # 방어 끄기 (재시작 없이 즉시 반영)
./lab on  state    # 다시 켜기

누가 내 비밀번호를 보는가?#

OAuth에는 네 개의 역할이 있습니다. Resource Owner(나), Client(앱), Authorization Server(인가서버), Resource Server(리소스서버).

이 중 사용자의 비밀번호를 직접 보는 것은 인가서버뿐입니다. 앱은 사용자의 비밀번호를 보지 않습니다. 이것이 OAuth가 존재하는 첫 번째 이유일 것입니다. “이 앱이 내 구글 캘린더를 읽게 해줘”라고 할 때, 앱에게 구글 비밀번호를 넘기지 않고도 제한된 권한만 위임할 수 있어야 하니까요.

그러면 앱은 비밀번호 대신 무엇을 받을까요. 토큰입니다. 그 토큰을 어떤 경로로 건네받는지가 다음 문제였습니다.

왜 토큰을 바로 주지 않고 code를 거치나?#

로그인 버튼을 누르면 무슨 일이 벌어지는지 한 홉씩 따라가 봤습니다. 한 번의 로그인은 HTTP 요청 일곱 개로 이뤄지는데, 그중 사용자가 실제로 보는 화면은 두 개였습니다.

로그인 1회 = HTTP 홉 7개브라우저302 → /authorize자동으로 지나감브라우저인가서버GET /authorize (동의 화면)사용자가 본다사용자인가서버POST /approve ("허용" 클릭)사용자가 본다인가서버브라우저302 → /callback?code=…자동으로 지나감브라우저GET /callback?code=…자동으로 지나감앱 서버인가서버POST /token → access token브라우저 밖브라우저GET / (결과 화면)사용자가 본다
초록만 사용자가 실제로 보는 화면입니다. ⑥에서 access token이 처음 등장하는데, 브라우저는 그 요청의 존재조차 모릅니다.

이 지점에서 두 개의 채널을 구분하는 게 핵심이었습니다.

  • front channel: 브라우저를 경유하는 통로입니다. 값이 URL에 실려 다니고, 브라우저 히스토리·리퍼러·서버 로그에 남습니다.
  • back channel: 앱 서버와 인가서버가 직접 주고받는 통로입니다. 브라우저는 이 대화를 보지 못합니다.

인가서버는 로그인이 끝나면 브라우저에게 code를 쥐여주고 앱으로 돌려보냅니다. 앱 서버는 그 code를 back channel에서 client_secret과 함께 토큰으로 바꿔옵니다. 그래서 access token은 브라우저 주소창에 단 한 번도 나타나지 않습니다.

왜 이렇게 번거롭게 할까요? access token을 front channel로 바로 주면(뒤에 나올 implicit 방식입니다) 토큰이 URL에 남아 공격 표면에 그대로 노출됩니다. code는 수명이 짧고, 1회용이며, client_secret이나 PKCE 없이는 토큰이 되지 못합니다. 그래서 code가 URL에 새더라도 그것만으로 할 수 있는 일은 없습니다.

이렇게 앱은 토큰을 손에 넣습니다. 그럼 그 토큰을 들고 리소스서버에 가면 무슨 일이 벌어질까요.

인가만으로 접근이 되나? 인증 없이?#

리소스서버를 만들면서 가장 오래 붙들었던 질문입니다. 저는 “인증(누구인지 확인)이 먼저고, 그 다음 인가(무엇을 할 수 있는지)“라는 순서로 이해하고 있었습니다. 그 순서는 맞습니다. 다만 그 순서가 어디에서 일어나느냐를 착각하고 있었습니다.

리소스서버의 검증 코드를 직접 짜보니, “누구세요?”를 묻는 줄이 한 줄도 없었습니다.

info, err := verifyToken(token)                 // 이 토큰 유효한가
if !info.Active                    { 401 }
if !hasScope(info.Scope, "notes:read") { 403 }  // 이 권한이 있는가
// 통과

리소스서버가 alice의 데이터를 내주는 것은 “요청자가 alice임을 확인해서”가 아니라 이 토큰이 그 데이터에 대한 권한을 담고 있어서입니다. 신원은 토큰을 발급하는 시점에 이미 권한 안에 반영돼 있었습니다.

이걸 실감한 실험이 있습니다. alice와 bob에게 각각 토큰을 발급받아 나란히 놓으면, 둘 다 그냥 랜덤 문자열입니다. 앱은 이 둘을 보고 누가 누구인지 구분하지 못합니다.

정리하면, 접근에 인증과 인가가 항상 둘 다 필요한 건 아닙니다. 구현에 따라 다릅니다. 전통적인 세션 쿠키 방식은 “누구인지”만 확인하고 권한은 서버가 그때그때 조회합니다. 반대로 OAuth의 Bearer 토큰은 “누구인지”는 안 보고 권한이 담긴 출입증만 보고 통과시킵니다. 리소스서버 관점에서는 인증 없이 인가만으로 접근이 가능한 셈입니다. 정확히 말하면 인증은 이미 토큰 발급 때 인가서버에서 일어났고, 리소스서버는 그것을 다시 확인하지 않을 뿐입니다.

Authorization: Bearer의 Bearer는 “소지자”라는 뜻입니다. 들고 있는 사람이 곧 권리자라서 무기명 채권이나 현금과 비슷합니다. 그래서 반드시 HTTPS로만 보내고, 수명을 짧게 두며, URL에 넣지 않습니다.

소지자면 통과한다는 건, 바꿔 말하면 이 값들이 남의 손에 들어가는 순간 끝이라는 뜻입니다. 그래서 다음으로 한 일은 직접 훔쳐보는 것이었습니다.

로그인을 남이 대신 시작시킬 수 있을까?#

첫 번째로 재현한 것은 세션 고정(session fixation), 흔히 로그인 CSRF라 부르는 공격입니다.

방향이 거꾸로라 처음엔 감이 안 왔습니다. 보통 CSRF는 “남이 내 계정으로 뭔가 하게” 만드는 것인데, 로그인 CSRF는 남이 나를 자기 계정에 로그인시키는 것입니다.

방어 없음: 공격 성공① 공격자(bob) 정상 로그인code만 받고 멈춘다② 그 code를 링크에 심어피해자에게 보낸다③ 피해자가 클릭/callback?code=…피해자 세션이 bob 계정에 묶인다화면은 "로�그인 완료", 이후 입력은 전부 bob 것state를 켜면앱이 /login 에서 심어둔 쿠키와 콜백의 state가 다르다 → 400. 공격자는 피해자 브라우저의 쿠키 값을 모른다.
공격자는 아무것도 훔치지 않습니다. 자기 계정을 열어두고 피해자를 그리로 밀어 넣을 뿐입니다.

공격자 사이트는 이렇게 생겼습니다. bob 계정으로 미리 받아둔 인가코드가 링크에 박혀 있습니다.

공격자 사이트(:9003). 버튼의 링크는 앱의 콜백 주소이고, 거기에 공격자 자신의 code가 붙어 있습니다.
공격자 사이트(:9003). 버튼의 링크는 앱의 콜백 주소이고, 거기에 공격자 자신의 code가 붙어 있습니다.

state 방어를 끄고 이 버튼을 누르면 이렇게 됩니다.

화면은 "로그인 완료"인데, 리소스서버가 돌려준 sub는 bob입니다. 메모도 bob의 것이 보입니다.
화면은 "로그인 완료"인데, 리소스서버가 돌려준 sub는 bob입니다. 메모도 bob의 것이 보입니다.

피해자는 자기 계정인 줄 알고 메모를 남기고 카드를 등록하지만, 그건 전부 bob의 것이 됩니다. bob은 나중에 자기 비밀번호로 로그인해서 그것을 다 가져갑니다.

나의 질문:

code를 안 바꾸고 들고 있다가 남에게 주면, 그 사람이 bob의 토큰으로 활동하게 되고, bob은 그냥 자기 계정으로 로그인해서 수확하는 거네.

공격자는 아무것도 훔칠 필요가 없습니다. 자기 계정을 미리 열어두고 피해자를 거기로 밀어 넣으면, 피해자가 알아서 정보를 채워줍니다.

이걸 보완하는 것이 state입니다. 앱이 로그인을 시작할 때 예측할 수 없는 랜덤값을 만들어 브라우저 쿠키에 저장해두고, 같은 값을 인가서버로 보냅니다. 콜백에서 돌아온 값과 쿠키의 값이 일치할 때만 code를 받아들입니다. 공격자는 피해자 브라우저의 쿠키 값을 알 수 없으니, 자기 code를 흘려보내도 여기서 막힙니다.

방어를 켜고 똑같은 링크를 다시 누르면 이렇게 됩니다.

같은 공격, 같은 링크. state 검증이 켜지자 콜백에서 거부됩니다.
같은 공격, 같은 링크. state 검증이 켜지자 콜백에서 거부됩니다.

핵심은 state는 비밀이 아니라는 것입니다. URL에 평문으로 실려도 됩니다. 필요한 건 예측할 수 없어야 한다는 것, 그리고 내 브라우저에만 있는 값과 대조한다는 것뿐입니다. 답하는 질문은 하나입니다. “이 콜백이 내가 시작한 로그인의 결과가 맞는가?“

redirect_uri를 살짝 바꾸면?#

state는 “내가 시작한 로그인인가”만 확인합니다. code 자체가 새는 것은 막지 못합니다. 두 번째 공격이 이 틈을 파고듭니다.

인가서버는 로그인이 끝나면 redirect_uri로 브라우저를 돌려보냅니다. 그런데 인가서버가 앞부분만 맞으면 통과시키는(prefix 매칭) 식으로 느슨하게 검사한다면, 공격자는 이런 값을 슬쩍 끼워 넣을 수 있습니다.

redirect_uri=http://127.0.0.1:9002/callback?next=http://127.0.0.1:9003/steal

여기에 앱의 흔한 실수 하나가 겹칩니다. “로그인 후 원래 보던 페이지로 돌려보내기”를 next 파라미터로 구현하면서 그 값을 검증하지 않으면, 그게 open redirect가 됩니다. 그러면 피해자가 자기 계정으로 정상 로그인했는데도 code가 앱을 거쳐 공격자 서버로 흘러갑니다.

피해자는 자기 계정으로 정상 로그인했는데, code는 공격자 서버(:9003)에 도착했습니다.
피해자는 자기 계정으로 정상 로그인했는데, code는 공격자 서버(:9003)에 도착했습니다.

이때 state는 왜 무력할까요? 피해자가 자기 브라우저에서 정상적으로 로그인했기 때문에 state 검증은 통과해버립니다. state는 “내가 시작한 로그인인가”에 답할 뿐, code가 어디로 향하는지는 관여하지 않습니다.

보완하는 방법은 redirect_uri를 등록값과 문자열 완전 일치로만 허용하는 것입니다(2.1의 규칙입니다). 그러면 ?next=가 붙은 순간 다른 문자열이라 인가서버가 문 앞에서 거부합니다.

동의 화면이 뜨기도 전에 인가서버가 거부합니다. 피해자가 무언가 할 기회조차 없습니다.
동의 화면이 뜨기도 전에 인가서버가 거부합니다. 피해자가 무언가 할 기회조차 없습니다.

이때 앱의 open redirect 버그는 그대로 남아 있습니다. 고치지 않았습니다. 그런데도 안전한 이유는 code가 애초에 그 버그 있는 경로로 배달되지 않기 때문입니다. 방어 하나가 다른 실수까지 덮어주는 셈입니다.

두 번째 방어는 인가코드를 1회용으로 만드는 것입니다. 이미 쓴 code가 다시 오면 그 code로 발급된 토큰까지 전부 폐기합니다. 재사용은 곧 탈취 신호이기 때문입니다.

code를 훔치면 끝인가?#

redirect_uri를 조여도 code는 다른 데로 샐 수 있습니다. 모바일 앱의 커스텀 스킴을 다른 앱이 가로챌 수도 있고, 로그에 남을 수도 있습니다. 특히 client_secret을 안전하게 보관할 수 없는 SPA나 모바일 앱, 그러니까 public client에서는 code만 있으면 누구나 토큰으로 바꿀 수 있습니다.

서버 앱은 되는데 모바일이나 SPA는 왜 client_secret을 못 쓸까요? 저장 공간의 문제가 아니라 “비밀을 지킬 수 있느냐”의 문제였습니다. 서버 앱의 secret은 내 서버 안에만 있습니다. 하지만 SPA는 코드가 전부 브라우저로 내려가고, 모바일 앱은 바이너리가 사용자 기기에 배포됩니다. secret이 사용자 손에 닿는 순간 그것은 더 이상 secret이 아닙니다.

그 빈자리를 메우는 게 PKCE입니다. 미리 심어둔 고정 secret 대신, 매 로그인마다 일회용 secret을 즉석에서 만듭니다.

code_verifier앱만 안다 (쿠키에 보관)SHA-256code_challengeURL로 보낸다 (노출됨)되돌릴 수 없음front channel: /authorize로 challenge 만 간다. 공격자가 볼 수 있다.back channel: /token에 verifier. 브라우저를 거치지 않는다.code를 훔쳐도verifier가 없으면invalid_grant
challenge는 URL에 실려 다녀도 안전합니다. SHA-256은 되돌릴 수 없어 challenge로 verifier를 구할 수 없습니다.

직접 손으로 계산해보면 이렇습니다.

V=$(cat /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 64)
C=$(printf %s "$V" | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=')

challenge/authorize로 보내고, 토큰을 바꿀 때 원본 verifier를 back channel로 보냅니다. 인가서버는 verifier를 다시 해시해서 저장해둔 challenge와 비교합니다.

# 올바른 verifier
curl ... -d "code_verifier=$V" "access_token": "eyJhbGciOiJSUzI1NiIs..."
# 훔친 code + 틀린 verifier
curl ... -d "code_verifier=WRONG" "code_verifier 불일치 (code 를 훔쳐도 verifier 없이는 못 씀)"

challenge는 URL에 실려 다니는데 왜 공격자가 그걸로 verifier를 못 만들까요? challengeverifier를 SHA-256으로 해시한 값이고, 해시는 단방향이라 되돌릴 수 없기 때문입니다. challenge는 새도 괜찮고, verifier는 브라우저를 거치지 않습니다.

state와 PKCE는 뼈대가 같습니다. “앱만 아는 값을 브라우저 쿠키에 두고, 나중에 대조한다.” 다만 막는 층위가 다릅니다. state는 콜백 CSRF를, PKCE는 code 가로채기를 막습니다.

여기까지가 code를 지키는 이야기입니다. 이제 그 code로 받아낸 토큰 쪽을 볼 차례였습니다.

리소스서버는 토큰을 어떻게 믿나?#

이번에는 토큰의 형식을 바꿔봤습니다. 지금까지 쓰던 opaque 토큰은 그냥 랜덤 문자열이라, 리소스서버는 매 요청마다 인가서버에 “이 토큰 유효해?”라고 물어봤습니다(introspection). 왕복이 생기고, 인가서버가 죽으면 리소스서버도 멈춥니다.

대안이 JWT입니다. 토큰 자체에 정보를 담고 서명을 붙이면, 리소스서버가 인가서버에 묻지 않고 서명만 로컬에서 검증할 수 있습니다.

JWT를 처음 열어보고 한 가지를 확인했습니다. payload를 아무 키 없이 디코드할 수 있었습니다.

# JWT의 base64url은 패딩이 없어 base64 -d 가 실패한다. 패딩을 붙여 디코드한다.
dec() { python3 -c "import sys,base64,json;p=sys.argv[1].split('.')[1];p+='='*(-len(p)%4);\
print(json.dumps(json.loads(base64.urlsafe_b64decode(p)),ensure_ascii=False,indent=2))" "$1"; }
dec "$TOK"
{
  "iss": "http://127.0.0.1:9000",
  "sub": "alice",
  "aud": "http://127.0.0.1:9001",
  "scope": "profile notes:read",
  "iat": 1789264062,
  "exp": 1789264662
}

payload는 누구나 볼 수 있고, 서명은 위조·변조를 확인하기 위한 것. JWT는 암호화가 아니라 서명입니다. 그래서 payload에 비밀번호 같은 민감정보를 넣으면 안 됩니다.

그리고 검증을 일부러 허술하게 두고 두 가지를 뚫어봤습니다.

alg:none. JWT header에는 어떤 알고리즘으로 서명했는지 적는 alg 필드가 있습니다. 그런데 이 header는 공격자가 만든 것입니다. algnone으로 바꾸면 “서명 없음”이라는 뜻이 되고, 리소스서버가 이걸 곧이곧대로 믿으면 서명 검사를 통째로 건너뜁니다.

b64() { base64 | tr '+/' '-_' | tr -d '='; }
CLAIMS='{"iss":"http://127.0.0.1:9000","aud":"http://127.0.0.1:9001","sub":"admin","scope":"profile notes:read","exp":9999999999}'

H=$(printf '{"alg":"none","typ":"JWT"}' | b64)
P=$(printf %s "$CLAIMS" | b64)
FAKE="$H.$P."      # 마지막 서명 자리가 비어 있다
./lab on  algnone  401
./lab off algnone  {"notes":null,"owner":"admin"}   # 통과!

보완하는 방법은 리소스서버가 alg를 미리 고정하는 것입니다. “나는 RS256만 받는다, header가 뭐라 하든.” “none만 거부”가 아니라 “RS256만 허용”으로 하는 이유는, 공개키를 secret처럼 악용하는 HS256 혼동 공격까지 함께 막기 위해서입니다.

aud 누락. 두 번째는 더 미묘합니다. 다른 서비스용으로 정상 발급된(서명이 완벽히 유효한) 토큰을 이 리소스서버에 들이미는 겁니다.

./lab on  aud  401
./lab off aud  200   # 서명은 진짜다. 다만 이 토큰은 :9005 용이다

서명이 유효한 것과, 나에게 유효한 것은 다릅니다. 서명은 “인가서버가 발급한 진짜 토큰”임만 보장하지, “이 토큰이 나를 위한 것”임은 보장하지 않습니다. 회사에 서비스가 여럿이고 하나가 허술하게 관리되다 토큰이 새면, aud를 안 보는 다른 서비스까지 그 토큰으로 넘어옵니다.

JWT 검증의 필수 항목은 순서대로 alg 화이트리스트 → 서명 → expissaud입니다. 하나라도 빠지면 구멍이 되는데, 가장 자주 빠지는 게 aud였습니다.

refresh token은 왜 필요한가? 털리면 끝 아닌가?#

JWT로 바꾸면서 새로운 문제가 생겼습니다. JWT는 즉시 회수가 안 됩니다. 리소스서버가 서명과 exp만 보고 통과시키니, 로그아웃을 해도, 계정을 정지시켜도 이미 발급된 JWT는 만료될 때까지 살아 있습니다.

이 대목에서 오래 고민한 것이 있습니다.

나의 질문:

어차피 앱 서버가 access token을 back channel에 들고 있고, 사용자에겐 세션 쿠키(sid)만 주잖아. 그럼 access token 유효기간을 아예 없애도 되는 거 아냐? 만료는 사용자가 로그아웃할 때 처리하고.

opaque 토큰이라면 이 설계가 성립합니다. 하지만 JWT에서는 성립하지 않습니다. exp를 없애면 영구 유효한 토큰이 되고, 로그아웃해도 리소스서버는 그 사실을 모릅니다. 죽이려면 매 요청 인가서버에 물어봐야 하는데, 그러면 introspection으로 되돌아가 JWT의 이점이 사라집니다.

정리하면 이렇습니다. 만료를 토큰 자신에게 맡기는 것이 JWT입니다. 성능을 위해 JWT를 선택한 순간 즉시 회수 능력을 포기한 것이고, 그 손실을 메우는 게 짧은 exp + refresh token입니다.

그러면 refresh token은 수명이 긴데, 그게 털리면 끝 아닌가라는 의문이 남습니다. 여기서 회전(rotation)과 재사용 감지가 등장합니다.

정상: 한 줄로만 뻗는다RT1RT2RT3점선 = 이미 쓴 마디(무효). 끝 하나만 유효하다.탈취: 지나간 마디가 다시 나타난다RT1RT2← 사용자공격자가 RT1을 다시 사용 → 분기 발생재사용 감지 → 계열(Family) 전체 폐기누가 진짜 주인인지 알 수 없으므로 둘 다 끊는다.멀쩡하던 RT2도 함께 죽고, 다시 로그인해야 한다.
정상이면 체인은 갈라지지 않습니다. 이미 소비된 마디가 다시 나타나면 복제됐다는 뜻이므로 계열 전체를 폐기합니다.

실습에서 이걸 재현했습니다.

# RT1 로 갱신 → RT2 발급 (RT1 은 소비됨)
# 그다음 이미 쓴 RT1 을 다시 쓰면
curl ... -d refresh_token=$RT1    "refresh token 재사용 감지, 이 Family 전체 폐기됨"
curl ... -d refresh_token=$RT2    "알 수 없는 refresh token"      # 멀쩡하던 것도 함께 죽는다
나의 질문:

일종의 refresh token 체인에 지나간 마디를 한 번 더 건드리면, 그 체인이 통째로 폐기되는 거네.

정상 흐름에서는 체인이 한 줄로만 뻗어 항상 끝 마디 하나만 살아 있습니다. 이미 소비된 마디가 다시 나타난다는 건 체인이 두 갈래로 갈라졌다는 뜻, 즉 복제됐다는 신호입니다.

RT2가 왜 함께 죽는지도 짚어볼 만합니다. 재사용을 감지한 시점에 인가서버는 지금 RT1을 쓰는 게 공격자인지 진짜 사용자인지 구분하지 못합니다. 그러니 의심되는 계열은 통째로 무효화하고 둘 다 다시 로그인하게 만듭니다.

여기까지 이해하고 나니 처음의 걱정이 풀렸습니다.

나의 질문:

refresh token이 털려도, 진짜 사용자가 언젠가 자기 것을 쓰는 순간 충돌이 나고, 그때 공격자의 것까지 함께 무효화되는구나.

다만 이건 완벽한 예방이 아니라 사후 감지입니다. 공격자가 최신 토큰만 훔쳐 사용자보다 앞서 계속 돌리면, 사용자가 다음 갱신을 시도할 때까지는 드러나지 않습니다. 그 공백까지 없애려면 refresh token을 클라이언트의 개인키에 묶는 sender-constrained 방식(DPoP, mTLS)이 필요합니다. 2.1이 “회전 또는 sender-constrained”라고 한 이유입니다. 회전은 감지, sender-constraint는 예방입니다.

그래서 refresh token은 단순히 “긴 토큰”이 아니라, 회전이라는 감지 장치를 붙일 수 있는 자리로서 존재합니다.

여기까지 만든 방어들을 늘어놓고 보니, 그 목록이 2.1이 요구하는 것과 거의 그대로 겹쳤습니다.

그래서 2.1은 무엇을 바꿨나?#

OAuth 2.1은 새로운 프로토콜이 아닙니다. 2.0에 그동안 쌓인 보안 권고를 합치고, 위험한 것을 빼낸 정리판입니다. 지금까지 만든 방어들이 대부분 2.1이 의무화한 항목입니다.

2.1 항목어디서 다뤘나
모든 클라이언트에 PKCE(S256) 필수code 가로채기
redirect_uri 문자열 완전 일치redirect_uri
인가코드 1회용 + 짧은 수명redirect_uri
refresh 회전 또는 sender-constrainedrefresh token
Bearer 토큰 쿼리스트링 전달 금지인가만으로 접근
Implicit 제거아래
ROPC 제거아래

특히 2.1이 제거한 두 가지를 직접 켜서 왜 없앴는지 봤습니다.

Implicit grant. code 단계 없이 access token을 URL fragment로 바로 주는 방식입니다.

Location: http://127.0.0.1:9002/callback#access_token=eyJhbGciOiJSUzI1NiIs...

토큰이 그대로 URL에 실려 나옵니다. 브라우저 히스토리와 리퍼러에 남고, code 단계가 없으니 PKCE로 보호할 수도 없습니다.

ROPC(비밀번호 직접 전달). 앱이 사용자의 아이디와 비밀번호를 직접 받아 인가서버로 넘기는 방식입니다. 서버 로그에 이렇게 찍힙니다.

[AS] ⚠️ ROPC: 앱이 넘긴 사용자 비번을 직접 받음 user=alice pass=hunter2

이건 앱이 비밀번호를 보지 않게 한다는 OAuth의 출발점을 되돌리는 것입니다. 앱이 비밀번호를 보는 순간 MFA도 소셜 로그인도 패스키도 무의미해집니다.

2.1의 방향은 하나입니다. 선택지를 줄여서 안전한 기본값만 남긴다. plain PKCE, implicit, ROPC, 느슨한 redirect_uri, 긴 code. 전부 “쓸 수는 있지만 위험한” 것들이었고, 2.1은 그것들을 아예 문법적으로 못 쓰게 만들었습니다. 이 목록은 사실 제가 앞에서 하나씩 익스플로잇해본 공격들의 목록과 정확히 겹칩니다.

앱은 내가 누군지 아는가?#

그런데 이걸 다 만들고도 남는 문제가 하나 있었습니다. 지금까지 만든 것은 OAuth 2.0과 2.1이고, 그 결과 앱은 여전히 로그인한 사용자가 누구인지 모릅니다.

이 빈칸을 채우는 것이 OIDC(OpenID Connect)입니다. scopeopenid를 넣으면 토큰 응답에 id_token이 함께 나옵니다. 두 토큰을 나란히 열어보면 차이가 드러납니다.

// id_token
{"aud": "demo-app", "sub": "alice", "nonce": "abc123", "auth_time": 1789264062}

// access token
{"aud": "http://127.0.0.1:9001", "sub": "alice", "scope": "openid profile notes:read"}

aud가 다릅니다. id_token은 앱 앞으로, access token은 리소스서버 앞으로 발급됩니다. 그래서 access token으로 로그인을 처리하면 안 됩니다. 그 토큰은 애초에 앱에게 “누구인지”를 말해주려고 만든 것이 아니기 때문입니다. 그렇게 하면 다른 서비스용으로 발급된 토큰을 주워다 로그인하는 것을 막을 수 없습니다. 앞에서 본 aud 공격과 같은 뿌리입니다.

id_token은 aud(수신자가 앱)와 nonce(내가 시작한 로그인인지), auth_time(언제 인증했는지)을 담아 “이 사람은 alice이고, 너(앱) 앞으로, 방금 인증했다”를 증명합니다. nonce는 id_token판 state인 셈입니다.

남은 것#

전체를 한 장으로 정리하면 이렇습니다.

인증: 누구인가OIDC · id_token (aud = 앱, nonce, auth_time)위임된 인가: 무엇을 할 수 있나OAuth 2.0 / 2.1 · access token (aud = 리소스서버, scope)방어 계층state · PKCE · redirect_uri 완전일치 · code 1회용 · JWT 검증(alg/aud) · refresh 회전
위 두 층이 무엇을 증명하는지, 아래 층이 그것을 어떻게 지키는지.

문서로 열 번 읽어도 잡히지 않던 것들이, 한 번 직접 익스플로잇해보니 손에 남았습니다. state를 껐다 켜보면서 남의 계정에 로그인당해보고, code가 공격자 서버로 흘러가는 걸 눈으로 보고, 서명 없는 토큰으로 admin이 되어보고 나서야 각 방어가 “무엇을 막으려고” 있는지가 분명해졌습니다.

OAuth 2.1이 기능을 자꾸 빼는 방향으로 간 이유도 그제야 납득이 갔습니다. 그 빠진 기능들은 전부 제가 직접 익스플로잇해본 것들이었으니까요. 보안에서 좋은 기본값이란, 위험한 선택지를 아예 고를 수 없게 만드는 것이었습니다.