OAuth 2.0 Authorization Code + PKCE의 동작 원리
GitHub, Google, Discord 같은 서비스를 이용해 로그인을 구현해 본 적이 있다면 다음과 비슷한 URL을 본 적이 있을 것이다.
https://auth.example.com/authorize
?response_type=code
&client_id=my-client
&redirect_uri=https://client.example.com/callback
&code_challenge=...
&code_challenge_method=S256
로그인이 끝난 뒤에는 다시 애플리케이션으로 돌아오면서 이런 값이 붙는다.
https://client.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA
그리고 이 code를 다시 인증 서버에 보내면 Access Token을 받을 수 있다.
처음 보면 한 가지 의문이 생긴다.
어차피 마지막에는 Access Token을 받아야 하는데, 왜 처음부터 Access Token을 주지 않고 굳이 Authorization Code라는 중간 단계를 거칠까?
여기에 한 가지 의문이 더 생긴다.
Authorization Code를 누군가 가로채면 결국 Access Token으로 교환할 수 있는 것 아닌가?
이 두 문제를 이해하면 Authorization Code Flow와 PKCE가 왜 존재하는지 거의 이해한 셈이다.
이 글에서는 단순히 OAuth 로그인 구현 코드를 따라 하는 대신, OAuth 2.0 Authorization Code Flow가 어떤 신뢰 모델을 가지고 있으며 PKCE가 그 위에서 정확히 무엇을 보장하는지 내부 동작을 따라가 본다.
1. OAuth는 '로그인 프로토콜'이 아니다
OAuth를 처음 접하면 흔히 다음과 같이 이해한다.
OAuth = 구글 로그인
엄밀히 말하면 그렇지 않다.
OAuth 2.0의 핵심 목적은 Authorization, 즉 권한 위임이다.
RFC 6749는 OAuth 2.0을 제3자 애플리케이션이 사용자의 자격 증명 자체를 받지 않고도 제한된 HTTP 리소스 접근 권한을 얻을 수 있도록 하는 프레임워크로 정의한다.
예를 들어 어떤 사진 인화 서비스가 사용자의 Google Photos 사진을 가져오고 싶다고 해보자.
가장 단순한 방법은 사용자가 Google 계정 비밀번호를 사진 인화 서비스에 알려주는 것이다.
사용자
│
│ Google ID / Password
▼
사진 인화 서비스
│
▼
Google
당연히 좋은 구조가 아니다.
사진 인화 서비스가 비밀번호를 알게 되고, 사용자가 허용한 범위보다 훨씬 많은 권한을 행사할 수도 있다.
OAuth는 이것을 다음과 같이 바꾼다.
사용자
│
│ 로그인 / 권한 승인
▼
Authorization Server
│
│ 제한된 권한
▼
Client
│
│ Access Token
▼
Resource Server
Client는 사용자의 비밀번호 대신 Access Token을 받는다.
Access Token에는 일반적으로 다음과 같은 제한을 둘 수 있다.
어떤 사용자에게 발급됐는가
어떤 Client를 위해 발급됐는가
어떤 Resource에 접근 가능한가
어떤 Scope를 가지고 있는가
언제 만료되는가
즉 OAuth의 핵심은 인증 정보를 공유하는 것이 아니라 권한을 위임하는 것이다.
2. OAuth에 등장하는 네 주체
Authorization Code Flow를 이해하려면 먼저 등장인물을 구분해야 한다.
예를 들어 우리가 어떤 개발 블로그 서비스에 GitHub 연동 기능을 만든다고 생각해보자.
Resource Owner
리소스의 실제 소유자.
사용자
Client
사용자의 권한을 위임받으려는 애플리케이션.
우리 개발 블로그
Authorization Server
사용자를 인증하고 Client에 권한을 발급하는 서버.
GitHub Authorization Server
Resource Server
Access Token을 받아 실제 API를 제공하는 서버.
GitHub API
실제로는 Authorization Server와 Resource Server를 같은 회사가 운영하는 경우가 많기 때문에 하나의 서버처럼 느껴진다.
그러나 OAuth 관점에서는 역할이 다르다.
3. 왜 Access Token을 바로 주지 않는가
과거 OAuth에서는 브라우저 Redirect를 통해 Access Token 자체를 전달하는 Implicit Grant라는 방식도 사용됐다.
개념적으로는 다음과 같다.
Client
│
▼
Authorization Server
│
│ 로그인
▼
Browser
│
│ access_token=...
▼
Client
여기에는 문제가 있다.
Access Token이라는 강력한 자격 증명이 브라우저를 통과해야 한다.
URL이나 브라우저 환경에 민감한 토큰이 직접 노출될수록 공격 표면이 커진다.
현재 OAuth 보안 권고인 RFC 9700은 Implicit Grant 대신 Authorization Code처럼 Access Token을 Token Endpoint에서 발급받는 방식을 사용할 것을 권고한다. 이렇게 하면 Access Token 자체가 Authorization Response URL에 노출되지 않는다.
Authorization Code Flow에서는 구조를 둘로 나눈다.
Front Channel
Browser
│
▼
Authorization Server
│
│ authorization_code
▼
Client
그리고 그 뒤:
Back Channel
Client
│
│ authorization_code
▼
Token Endpoint
│
│ access_token
▼
Client
브라우저를 통해 전달하는 값은 Access Token 자체가 아니라 짧게 살아 있는 일회성 Authorization Code다.
이것이 중요한 첫 번째 보안 경계다.
4. Authorization Code Flow 전체 흐름
PKCE를 제외하고 가장 기본적인 Authorization Code Flow부터 보자.
┌─────────┐
│ Browser │
└────┬────┘
│
│ 1. Login 요청
▼
┌────────────┐
│ Client │
└────┬───────┘
│
│ 2. /authorize
▼
┌──────────────────────┐
│ Authorization Server │
└────┬─────────────────┘
│
│ 3. 사용자 로그인 및 동의
│
│ 4. code 발급
▼
┌────────────┐
│ Client │
└────┬───────┘
│
│ 5. POST /token
│ code=ABC
▼
┌──────────────────────┐
│ Authorization Server │
└────┬─────────────────┘
│
│ 6. Access Token
▼
┌────────────┐
│ Client │
└────────────┘
조금 더 실제 HTTP 요청처럼 보면 다음과 같다.
4.1 Authorization Request
Client가 브라우저를 Authorization Server로 이동시킨다.
GET /authorize?
response_type=code&
client_id=codelab&
redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback&
scope=profile&
state=a8f37c
사용자가 로그인하고 권한을 승인한다.
4.2 Authorization Response
Authorization Server가 Client로 Redirect한다.
HTTP/1.1 302 Found
Location: https://client.example.com/callback
?code=abc123
&state=a8f37c
여기서:
abc123
이 Authorization Code다.
4.3 Token Request
Client는 받은 Authorization Code를 Token Endpoint에 제출한다.
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=abc123&
redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback&
client_id=codelab
인증 서버가 검증에 성공하면:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600
}
처럼 Access Token이 발급된다.
5. 그런데 Authorization Code를 훔치면?
여기서 문제가 생긴다.
Authorization Code가 브라우저를 통해 이동한다.
Authorization Server
│
│ ?code=abc123
▼
Browser
│
▼
Client
그렇다면 공격자가 어떤 방법으로든:
abc123
을 알아냈다고 생각해보자.
공격자가 먼저 Token Endpoint로 요청한다.
POST /token
grant_type=authorization_code
code=abc123
Authorization Server가 이것만 확인한다면 정상 Client와 공격자를 구분하기 어렵다.
정상 Client: code=abc123
공격자: code=abc123
서버 입장에서는 둘 다 같은 Code를 들고 있기 때문이다.
Confidential Client에서는 client_secret을 이용한 Client Authentication이 하나의 보호 장치가 될 수 있다.
그러나 여기서 또 다른 문제가 등장한다.
6. 모바일 앱이나 SPA에는 진짜 Client Secret이 없다
서버 애플리케이션이라면 다음 값을 서버 환경 변수에 저장할 수 있다.
CLIENT_SECRET=7a1b9c...
외부 사용자는 서버 내부 환경 변수에 접근할 수 없다.
그래서 이런 애플리케이션을 OAuth에서는 일반적으로 Confidential Client로 볼 수 있다.
하지만 모바일 앱은 다르다.
예를 들어 Android APK 안에:
const CLIENT_SECRET = "super-secret-value";
를 넣었다고 생각해보자.
APK는 사용자에게 그대로 배포된다.
분석하면 결국 값을 추출할 수 있다.
SPA도 마찬가지다.
const CLIENT_SECRET = "super-secret";
이 JavaScript는 결국 사용자의 브라우저로 다운로드된다.
따라서:
Browser SPA
Mobile App
Desktop App
같은 환경에서는 모든 설치본이 공유하는 client_secret을 진짜 비밀로 간주할 수 없다.
이런 Client를 Public Client라고 부른다.
그러면 질문이 생긴다.
Client Secret을 안전하게 보관할 수 없는 Client에서 Authorization Code를 어떻게 보호할 것인가?
PKCE가 여기서 등장한다.
7. PKCE: Proof Key for Code Exchange
PKCE는 다음과 같이 읽는다.
Proof Key for Code Exchange
보통:
픽시
라고 발음한다.
PKCE의 아이디어는 의외로 단순하다.
로그인을 시작하는 순간 Client가 이번 인증 요청에만 사용할 일회성 비밀값을 하나 만든다.
code_verifier
예:
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
RFC 7636에서 code_verifier는 암호학적으로 안전한 랜덤 문자열이며 Authorization Request와 Token Request를 연결하는 데 사용된다.
중요한 것은:
Client Secret
과 달리 모든 앱이 같은 값을 사용하는 것이 아니라:
Login #1 → verifier A
Login #2 → verifier B
Login #3 → verifier C
처럼 인증 트랜잭션마다 새 값을 만든다는 점이다.
RFC 9700에서도 PKCE Challenge는 트랜잭션마다 고유해야 하며 Client와 해당 인증을 시작한 User Agent에 안전하게 연결돼야 한다고 권고한다.
8. code_verifier를 그대로 보내지 않는다
Client가 먼저 만든 값이:
code_verifier
다.
하지만 Authorization Request 단계에서 이 값을 그대로 보내면 문제가 있다.
Authorization Request는 브라우저를 통과한다.
Client
│
│ code_verifier
▼
Browser
│
▼
Authorization Server
공격자가 Authorization Request까지 관찰할 수 있다면 code_verifier도 같이 알아낼 수 있다.
그래서 실제로는 verifier 자체가 아니라 verifier에서 파생된 값을 보낸다.
그게:
code_challenge
다.
9. S256은 정확히 무엇을 계산하는가
현재 일반적으로 사용하는 PKCE 변환 방식은:
S256
이다.
공식은 다음과 같다.
code_challenge =
BASE64URL(
SHA256(
ASCII(code_verifier)
)
)
RFC 7636에도 S256 변환이 이 형태로 정의돼 있다.
흐름으로 보면:
code_verifier
│
│ ASCII bytes
▼
SHA-256
│
│ 256-bit digest
▼
Base64URL
│
▼
code_challenge
예를 들어:
code_verifier
=
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
가 있다면 이것을 SHA-256 해싱한 후 Base64URL 인코딩해서 code_challenge를 만든다.
여기서 중요한 점은 암호화가 아니라 해시라는 것이다.
서버가 나중에 Challenge를 복호화해서 Verifier를 알아내는 구조가 아니다.
서버도 똑같은 계산을 다시 할 뿐이다.
Client:
verifier
│
SHA256
│
▼
challenge
그리고 나중에:
Server:
received verifier
│
SHA256
│
▼
calculated challenge
==
stored challenge
를 비교한다.
10. PKCE를 포함한 전체 인증 과정
이제 전체 Flow를 다시 보자.
Step 1. Client가 verifier 생성
Client:
code_verifier = random()
이 값은 Client 쪽에만 보관한다.
Step 2. challenge 계산
code_challenge =
BASE64URL(SHA256(code_verifier))
Step 3. Authorization Request
브라우저에는 Verifier가 아니라 Challenge만 보낸다.
GET /authorize?
response_type=code&
client_id=codelab&
redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
Authorization Server는 이 Challenge를 해당 인증 트랜잭션과 연결해서 기억한다.
개념적으로:
Transaction #18492
client_id = codelab
redirect_uri = https://client.example.com/callback
code_challenge = E9Melhoa...
method = S256
11. 로그인 후 Authorization Code 발급
사용자의 로그인이 완료되면:
HTTP/1.1 302 Found
Location:
https://client.example.com/callback?code=abc123
이 시점에 Authorization Server 내부에서는 개념적으로 다음 관계가 만들어져 있다고 볼 수 있다.
authorization_code = abc123
│
├── client_id
├── redirect_uri
├── user
├── scope
└── code_challenge
즉 Authorization Code와 앞서 전달받은 Challenge가 묶여 있다.
12. Token Request에서 verifier 공개
이제 Client는 처음부터 보관하고 있던 code_verifier를 제출한다.
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=abc123&
client_id=codelab&
redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
여기서 처음으로 Verifier가 등장한다.
중요한 차이가 있다.
Authorization Request는 보통 브라우저 Redirect라는 Front Channel을 이용하지만, Token Request는 Authorization Server의 Token Endpoint를 직접 호출한다.
그리고 OAuth 통신 자체는 TLS를 사용해야 한다.
13. Authorization Server의 실제 검증
서버는 abc123 Authorization Code에 연결돼 있던 값을 조회한다.
stored_challenge = E9Melhoa...
그리고 Token Request에 들어온:
code_verifier
로 다시 계산한다.
calculated_challenge =
BASE64URL(
SHA256(code_verifier)
)
마지막으로:
calculated_challenge == stored_challenge
인지 비교한다.
같으면:
PKCE verification success
그리고 Access Token을 발급한다.
다르면:
invalid_grant
등의 오류로 요청을 거부한다.
RFC 9700은 Authorization Request에 유효한 code_challenge가 포함됐다면 Authorization Server가 Token Endpoint에서 반드시 올바른 code_verifier 사용을 강제해야 한다고 명시한다.
14. 그래서 Code를 훔쳐도 사용할 수 없다
이제 공격 상황을 보자.
정상 Client:
verifier = SECRET_A
그리고 서버에는:
challenge = SHA256(SECRET_A)
가 저장돼 있다.
Authorization Server가:
code=CODE123
을 발급한다.
공격자가 우연히 이것을 가로챘다고 해보자.
Attacker
CODE123
공격자는 Token Endpoint에 요청할 수 있다.
POST /token
code=CODE123
code_verifier=???
하지만 정상 Client가 만든:
SECRET_A
를 모른다.
공격자가 임의로:
code_verifier=SECRET_B
를 제출하면 서버 계산은:
SHA256(SECRET_B)
가 된다.
서버에 저장된 것은:
SHA256(SECRET_A)
이므로:
SHA256(SECRET_A)
!=
SHA256(SECRET_B)
검증에 실패한다.
결국 공격자는:
Authorization Code
는 가지고 있지만 그것을 사용할 Proof가 없다.
바로 이 때문에 이름이:
Proof Key for Code Exchange
인 것이다.
RFC 9700 역시 PKCE가 도난당한 Authorization Code를 공격자가 code_verifier 없이 Token Endpoint에서 사용할 수 없게 만든다고 설명한다.
15. PKCE는 '또 다른 Client Secret'이 아니다
여기서 개념적으로 중요한 부분이다.
PKCE를 다음처럼 생각하면 조금 틀리다.
PKCE = Client Secret 대체품
Client Secret과 Code Verifier는 성질이 다르다.
Client Secret:
Client 전체의 장기 Credential
모든 로그인에서 같은 값
Code Verifier:
특정 Authorization Transaction의 일시적인 Secret
매 로그인마다 다른 값
예를 들어:
Client Secret
App ───────────── SECRET_ABC
├ Login 1
├ Login 2
├ Login 3
└ Login 4
반면 PKCE는:
App
├ Login 1 ─── verifier_A
├ Login 2 ─── verifier_B
├ Login 3 ─── verifier_C
└ Login 4 ─── verifier_D
이다.
따라서 PKCE의 핵심은 Client 자체의 신원을 증명하는 것이 아니라:
이 Authorization Code를 교환하려는 주체가 원래 Authorization Request를 시작했던 Client 인스턴스인가?
를 확인하는 데 가깝다.
16. 왜 SHA-256을 쓰는가
그렇다면 이런 의문도 생긴다.
어차피 랜덤한 Verifier인데 그냥 Challenge로 Verifier 자체를 보내도 되지 않을까?
실제로 RFC 7636에는:
plain
이라는 방식도 정의돼 있다.
code_challenge = code_verifier
그러나 이렇게 하면 Authorization Request를 관찰할 수 있는 공격자는 Challenge를 보는 순간 Verifier도 알아버린다.
Authorization Request
code_challenge=SECRET_A
그 자체가 비밀값이다.
반면 S256에서는:
Authorization Request
code_challenge=SHA256(SECRET_A)
만 공개된다.
공격자가 Challenge를 얻더라도 원래 Verifier를 알아내야 한다.
Verifier가 충분히 랜덤하고 길다면 현실적으로 역산할 수 없다.
RFC 7636은 S256을 사용할 수 있는 Client라면 S256을 사용하도록 정의하고 있으며, RFC 9700에서도 현재 Verifier를 Authorization Request에 노출하지 않는 방법으로 S256을 사용하도록 권고하고 있다.
즉 현대 구현에서:
code_challenge_method=plain
을 새로 선택할 이유는 거의 없다.
기본적으로:
S256
을 사용하면 된다.
17. SHA-256의 목적은 'Verifier 보관 암호화'가 아니다
이 부분도 자주 오해한다.
SHA256을 사용한다
→ Verifier를 암호화해서 안전하게 전달한다
가 아니다.
Verifier는 Authorization 단계에서는 아예 전달하지 않는다.
먼저:
SHA256(verifier)
라는 Commitment에 가까운 값을 보내 놓고,
나중에:
verifier
를 제시해서:
이 Challenge를 만든 원본 값을 내가 알고 있다.
는 것을 증명하는 구조다.
이를 아주 단순화하면:
Client:
"내가 어떤 비밀 X를 알고 있어.
지금은 H(X)만 줄게."
↓
Server:
H(X)를 저장
↓
나중에 Client:
"아까 X는 이거였어."
↓
Server:
H(받은 X) == 저장한 H(X)
↓
확인
이 모델을 이해하면 PKCE의 구조가 훨씬 자연스럽다.
18. PKCE와 state는 같은 기능인가?
OAuth 구현을 보면 보통 둘이 같이 등장한다.
state
code_verifier
code_challenge
그래서 둘을 혼동하기 쉽다.
전통적으로 역할을 구분하면:
state
Authorization Request와 Response를 특정 브라우저 세션에 연결한다.
주요 목적 중 하나는 CSRF 방어다.
Client:
state=random_A
Authorization Response:
/callback?
code=123&
state=random_A
Client가 처음 저장해둔 값과 비교한다.
PKCE
Authorization Request와 Token Request를 암호학적으로 연결한다.
Authorization Request
│
│ challenge
▼
Authorization Code
│
│ verifier
▼
Token Request
즉 전통적인 설명은:
state
→ Browser Authorization Transaction 보호
PKCE
→ Authorization Code 교환 보호
라고 이해하면 좋다.
다만 현재 보안 권고는 조금 더 복잡하다.
RFC 9700에서는 PKCE를 적절히 적용한 경우 PKCE 자체가 CSRF에 대해서도 강력한 보호를 제공할 수 있다고 설명한다. 하지만 PKCE 지원 여부가 보장되지 않는 경우에는 state 또는 OpenID Connect의 nonce 같은 별도 CSRF 방어가 필요하다.
따라서 실무에서 단순히:
"PKCE 쓰니까 state는 무조건 삭제해도 된다"
라고 판단하는 것은 좋지 않다.
특히 state에 애플리케이션 상태도 함께 저장한다면 별도의 무결성 보호 문제도 고려해야 한다.
19. Authorization Code는 그냥 랜덤 문자열이 아니다
Authorization Code를 단순히:
랜덤 문자열 → Access Token 교환권
이라고만 보면 중요한 부분을 놓치게 된다.
안전한 Authorization Server에서는 Code가 여러 정보에 묶여 있어야 한다.
개념적으로:
Authorization Code
│
├── Client ID
├── Redirect URI
├── Resource Owner
├── Granted Scope
├── PKCE Challenge
├── Expiration
└── Used 여부
즉:
code=abc123
하나만 맞다고 무조건 토큰을 주는 구조가 아니다.
특히 Authorization Code는 일반적으로:
짧은 수명
+
1회 사용
이어야 한다.
한 번 Token으로 교환된 Code를 다시 사용하려는 시도는 실패해야 한다.
이 때문에 Authorization Code는 Access Token과 성격이 상당히 다르다.
20. PKCE Downgrade Attack
조금 더 깊게 들어가 보자.
Client가 원래:
code_challenge=AAA
code_challenge_method=S256
을 보냈다고 하자.
그런데 구현이 잘못돼 Token Endpoint가 이런 요청도 받아준다고 생각해보자.
code=CODE123
code_verifier 없음
그러면 공격자는 PKCE 검증을 그냥 생략하도록 만들 수 있다.
즉:
PKCE 지원
↓
공격자가 PKCE 없는 Flow로 강등
↓
보호 무력화
가 된다.
이를 PKCE Downgrade Attack 문제로 볼 수 있다.
따라서 Authorization Server는 Authorization Request에 Challenge가 있었다면:
Token Request에 verifier가 반드시 있어야 함
을 강제해야 한다.
또 반대로 Token Request에 code_verifier가 등장했다고 해서 무조건 PKCE Flow로 인정해서도 안 된다.
원래 Authorization Request에 Challenge가 있었는지를 확인해야 한다.
RFC 9700은 Authorization Server가 이러한 PKCE Downgrade Attack을 방지해야 한다고 명시한다.
21. redirect_uri 검증도 PKCE와 별개의 문제다
PKCE를 적용하면 OAuth 보안 문제가 전부 해결된다고 생각하면 안 된다.
예를 들어 Authorization Server가 Redirect URI를 느슨하게 검증한다고 생각해보자.
등록된 값:
https://client.example.com/callback
공격자가:
https://attacker.example.com/callback
으로 Code를 유도할 수 있다면 Authorization Code 유출 가능성이 생긴다.
또 다음 같은 패턴도 위험할 수 있다.
https://client.example.com/redirect?target=https://attacker.example
즉 Open Redirector가 있으면 인증 Response가 다른 곳으로 흘러갈 가능성이 있다.
현재 OAuth Security BCP인 RFC 9700은 Redirect URI 비교에 원칙적으로 정확한 문자열 비교를 요구하고 Open Redirector를 피하도록 권고한다.
PKCE는 Code가 유출됐을 때 악용하기 어렵게 만드는 계층이지, Code 유출 자체를 허용해도 된다는 의미가 아니다.
보안은 여러 방어 계층으로 구성된다.
22. PKCE가 Access Token 탈취까지 막아주는 것은 아니다
PKCE의 보호 범위를 정확하게 이해해야 한다.
PKCE가 보호하는 핵심 대상은:
Authorization Code
다.
이미 발급된:
Access Token
이 탈취됐다면 PKCE가 이를 다시 보호해주는 것은 아니다.
예를 들어:
PKCE 성공
│
▼
Access Token 발급
│
▼
Token 탈취
가 발생하면 공격자는 Token 자체를 사용할 수 있을 가능성이 있다.
그래서 현재 OAuth 보안에서는 별도로 Sender-Constrained Token 같은 개념도 사용한다.
대표적으로:
mTLS
DPoP
등이 있다.
RFC 9700 역시 탈취된 Access Token의 재사용을 줄이기 위해 sender-constrained access token 메커니즘을 권고한다.
즉:
PKCE
→ Authorization Code 보호
DPoP / mTLS
→ Access Token 사용 주체 제한
처럼 서로 다른 보안 계층이다.
23. PKCE는 이제 모바일 앱만을 위한 기술이 아니다
PKCE는 원래 Native App 환경에서 Authorization Code Interception 문제를 해결하기 위해 만들어졌다.
하지만 현재 권고는 훨씬 넓다.
RFC 9700에서는:
Public Client
→ PKCE MUST
Confidential Client
→ PKCE RECOMMENDED
라는 방향으로 권고한다.
그리고 PKCE가 처음에는 Native App 보호를 위해 만들어졌지만 현재 권고는 웹 애플리케이션을 포함한 모든 종류의 OAuth Client에 적용된다고 명시한다.
즉:
"우리 서버에는 Client Secret이 있으니까 PKCE 필요 없음"
이라는 접근은 현대 OAuth 보안 권고와는 거리가 있다.
PKCE와 Client Authentication은 서로 완전히 동일한 문제를 해결하는 기술이 아니다.
24. 실제 서버에서는 무엇을 저장해야 할까
구현 관점에서 보면 Authorization Server가 PKCE Flow를 처리할 때 Authorization Code와 다음 정보를 연결해 두는 구조를 생각할 수 있다.
예:
{
"code_hash": "...",
"client_id": "codelab",
"user_id": "12345",
"redirect_uri": "https://client.example.com/callback",
"scope": ["profile", "email"],
"code_challenge": "E9Melhoa...",
"code_challenge_method": "S256",
"expires_at": 1787650000,
"used": false
}
실제 제품 구현에서는 Authorization Code 자체를 DB에 평문으로 그대로 저장할 필요가 없는 설계도 가능하다.
예를 들어 Code를 충분히 랜덤하게 생성하고 서버에는:
hash(code)
만 저장하는 방법도 고려할 수 있다.
Authorization Code DB가 유출됐을 때 활성 Code의 직접적인 악용 가능성을 줄일 수 있기 때문이다.
이 부분은 PKCE 표준 자체에서 반드시 요구하는 구조라기보다는 인증 서버를 설계할 때 추가로 고려할 수 있는 방어적 구현 방법에 가깝다.
25. Code Verifier는 어떻게 생성해야 하는가
PKCE에서 Verifier는 사실상 이번 인증 트랜잭션의 비밀값이다.
따라서:
Math.random()
처럼 보안용 난수 생성기가 아닌 값을 사용하는 것은 좋지 않다.
브라우저라면 예를 들어:
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
처럼 CSPRNG를 사용해야 한다.
그 후 URL-safe 문자열로 변환한다.
개념적으로:
CSPRNG
│
▼
random bytes
│
▼
Base64URL
│
▼
code_verifier
RFC 7636에서 Verifier는 43~128자의 unreserved 문자로 정의된다.
그리고 Challenge는:
SHA256(codeVerifier)
를 수행한 후 Base64URL Encoding한다.
주의할 점은 일반 Base64와 Base64URL이 동일하지 않다는 것이다.
대략:
Base64 Base64URL
+ → -
/ → _
= → 제거
형태의 차이가 있다.
여기서 구현을 잘못해 PKCE 검증이 실패하는 경우도 많다.
26. Verifier를 LocalStorage에 넣으면 괜찮을까?
SPA에서 PKCE를 직접 구현하다 보면:
code_verifier를 어디에 저장하지?
라는 문제가 생긴다.
Authorization Server로 이동했다 다시 돌아와야 하기 때문에 Client는 Verifier를 잠시 기억해야 한다.
가장 편한 방법은:
localStorage.setItem("code_verifier", verifier);
다.
하지만 LocalStorage 값은 같은 Origin에서 실행되는 JavaScript에서 접근할 수 있다.
따라서 XSS가 존재한다면 공격자가 Verifier뿐 아니라 애플리케이션의 다른 민감 데이터에 접근할 가능성이 생긴다.
PKCE는 XSS를 해결하는 기술이 아니다.
즉:
PKCE 적용
≠
Browser App 전체가 안전함
이다.
구현 환경과 인증 라이브러리에 따라 Session Storage, 메모리 기반 상태 관리, Backend-for-Frontend 구조 등 서로 다른 설계를 검토하게 된다.
중요한 것은 Code Verifier 역시 해당 인증 트랜잭션 동안 보호해야 할 값이라는 점이다.
27. PKCE와 OAuth의 보안 모델을 한 문장으로 보면
PKCE를 가장 간단히 설명하면 보통 이렇게 말한다.
Authorization Code를 훔쳐도 Code Verifier가 없으면 Access Token으로 교환할 수 없게 한다.
맞는 설명이다.
하지만 내부 구조까지 포함해 조금 더 정확하게 표현하면:
PKCE는 Authorization Request 때 Client가 일회성 비밀값으로부터 만든 Challenge를 Authorization Server에 미리 커밋하고, Token Request 때 원본 Verifier를 제출하도록 함으로써 Authorization Code를 최초 인증 트랜잭션을 시작한 Client 인스턴스에 암호학적으로 결속한다.
라고 볼 수 있다.
전체 구조를 다시 그리면 다음과 같다.
CLIENT
──────────────────────────────────────────
CSPRNG
│
▼
code_verifier
│
├──────────────────────────────┐
│ │
SHA256 │ 보관
│ │
Base64URL │
│ │
▼ │
code_challenge │
│ │
│ │
▼ │
AUTHORIZATION SERVER │
──────────────────────────── │
│
challenge 저장 │
│
사용자 인증 │
│
Authorization Code 발급 │
│
│ │
▼ │
Client ◄──────────────────┘
│
│ code
│ verifier
▼
TOKEN ENDPOINT
────────────────────────────
SHA256(received verifier)
│
▼
calculated challenge
│
│ compare
▼
stored challenge
│
match?
/ \
No Yes
│ │
Reject ▼
Access Token
28. 결국 PKCE가 해결한 것은 '공유 비밀'의 문제다
Public Client에서는 모든 설치본에 동일한 Client Secret을 넣는 방식으로는 신뢰를 만들 수 없다.
100만 명에게 배포된 앱
SECRET = ABC123
이라면 그것은 사실상 Secret이 아니다.
PKCE는 이 문제를 다른 방향으로 해결했다.
앱 전체가 공유하는 장기 비밀
을 믿는 대신:
각 인증 시도에서 새로 생성되는 일회성 비밀
을 만든다.
그리고 Authorization Request와 Token Request 사이를 그 비밀로 묶는다.
이 설계 덕분에 Client가 장기 Secret을 안전하게 보관할 수 없는 환경에서도 Authorization Code Flow를 훨씬 안전하게 사용할 수 있게 됐다.
마무리
Authorization Code + PKCE를 처음 보면 꽤 복잡해 보인다.
code
code_verifier
code_challenge
state
client_id
redirect_uri
같은 값이 한꺼번에 등장하기 때문이다.
하지만 각각의 역할을 공격 시나리오에서 보면 구조는 꽤 논리적이다.
Access Token을 Browser에 직접 노출시키고 싶지 않다.
↓
Authorization Code를 중간에 사용한다.
Authorization Code가 탈취될 수 있다.
↓
PKCE로 Code를 최초 Client에 묶는다.
Public Client는 Client Secret을 안전하게 저장할 수 없다.
↓
매 인증마다 새로운 code_verifier를 만든다.
Verifier를 Authorization Request에서 노출하고 싶지 않다.
↓
SHA-256 기반 code_challenge를 먼저 보낸다.
Token Request가 왔다.
↓
Verifier를 다시 해시해 기존 Challenge와 비교한다.
결국 핵심은 다음 한 줄로 압축할 수 있다.
Authorization Code + PKCE
=
Code를 가지고 있다는 사실만으로는 충분하지 않고,
그 Code를 만들어낸 인증 트랜잭션의 비밀값까지
알고 있음을 증명하게 만드는 구조
그리고 이것이 단순히 OAuth 로그인 API 사용법을 외우는 것과 OAuth의 보안 설계를 이해하는 것의 차이다.
참고한 표준
- RFC 6749 — The OAuth 2.0 Authorization Framework: OAuth 2.0과 Authorization Code Grant의 기본 구조.
- RFC 7636 — Proof Key for Code Exchange by OAuth Public Clients:
code_verifier,code_challenge,S256등 PKCE의 프로토콜 정의. - RFC 9700 — Best Current Practice for OAuth 2.0 Security: 2025년에 발표된 현재 OAuth 2.0 보안 BCP로, Public Client의 PKCE 사용과 Confidential Client에 대한 PKCE 권고, Redirect URI 검증 및 최신 공격 모델을 다룬다.

.png)