✍️ 필사 모드: OAuth 2.0 & OIDC Deep Dive — Authorization Code, PKCE, JWT, DPoP, FAPI 완전 정복 (2025)
한국어TL;DR
- OAuth 2.0은 인가(authorization) 프로토콜이지 인증(authentication)이 아니다. "이 앱이 내 Gmail을 읽게 허락한다"는 위임이지 "내가 누구인지 증명한다"가 아니다.
- **OpenID Connect (OIDC)**는 OAuth 2.0 위에 인증 레이어를 추가. ID Token(JWT)을 발급해 "사용자가 누구인지"를 표현.
- 4대 flow: Authorization Code (+ PKCE), Client Credentials, Device Code, Refresh Token. Implicit와 Password는 OAuth 2.1에서 폐기.
- PKCE: 초기에는 모바일 앱의 공개 client를 위한 확장이었지만, OAuth 2.1에서 모든 client에 필수. 인증 코드 가로채기 공격 방어의 핵심.
- JWT (JSON Web Token): Base64URL 인코딩된
header.payload.signature. 자체 포함(self-contained) 토큰. 빠르지만 취소 어려움. - OIDC Discovery:
/.well-known/openid-configuration엔드포인트가 모든 URL/키/알고리즘을 광고. 클라이언트가 자동 설정. - JWKS (JSON Web Key Set): 서명 공개 키를 JSON으로 배포. 키 롤오버 가능.
- Token binding: 토큰을 특정 client/TLS 연결에 묶기. DPoP(proof-of-possession), mTLS binding.
- FAPI: 금융급 보안 프로파일. Read/Write 두 단계, PAR, JARM 등 추가 보호.
- 실전 제공자: Auth0, Okta, Keycloak(오픈소스), AWS Cognito, Azure AD, Google, GitHub, Apple Sign-In.
1. OAuth의 탄생
1.1 암흑 시대 (2000년대 초)
웹 앱이 Gmail을 읽고 싶다면? 2006년 방식:
앱: "당신의 Gmail 비밀번호를 알려주세요."
사용자: "..."
앱: Gmail에 IMAP 로그인 → 받은 편지함 읽음.
말도 안 된다. 문제:
- 앱이 비밀번호를 영구 저장. 해킹되면 모든 것이 털림.
- 권한 분리 없음: 앱이 Gmail의 모든 것(삭제 포함)을 할 수 있음.
- 취소 어려움: 권한을 돌려주려면 비밀번호 변경.
- 2FA 불가: 2차 인증이 앱 자동화를 방해.
1.2 OAuth 1.0 (2007)
Twitter, Ma.gnolia, Flickr 등이 개발한 권한 위임 프로토콜. 핵심 아이디어:
- Resource Owner: 사용자.
- Client: 앱.
- Resource Server: API (예: Twitter API).
- Authorization Server: 권한 승인 서버.
흐름:
- 사용자가 앱에서 "Twitter 연결" 클릭.
- Twitter가 로그인 화면 표시.
- 사용자가 승인.
- Twitter가 앱에게 토큰 발급.
- 앱은 비밀번호 대신 토큰으로 API 호출.
토큰은 취소 가능, 스코프 제한 가능, 앱마다 별도. 비밀번호 공유보다 훨씬 안전.
그러나 OAuth 1.0은 복잡했다. 요청마다 HMAC 서명 필요, 타임스탬프, nonce, 매우 많은 파라미터. 개발자들이 "너무 어렵다"고 불평.
1.3 OAuth 2.0 (2012)
RFC 6749로 표준화. 주요 변경:
- 단순화: 서명 대신 TLS 사용. 토큰은 bearer token (소지자가 사용).
- 프레임워크: 구체적인 흐름이 여러 개, 용도별로 다름.
- 확장성: extension point 많음 → 후에 여러 RFC가 추가.
단순화의 대가: 보안 책임이 구현자에게 전가. OAuth 2.0 자체는 안전하게 "사용하면" 안전하지만 잘못 사용하기 쉽다. 초기에 보안 사고 많았다.
1.4 OIDC (2014)
OAuth 2.0은 인가(authorization) 프로토콜이지만 사람들이 인증(authentication)에 썼다. "Facebook으로 로그인" 같은 기능.
문제: OAuth로 access token을 받으면 "이 사용자가 진짜 Facebook에 로그인했다"는 증명이 아니다. 토큰은 그냥 권한을 의미할 뿐.
OpenID Connect가 이 간극을 메꿨다. OAuth 2.0 위에 ID Token을 추가:
- ID Token = 사용자 신원 정보 + 서명된 JWT.
- "user@example.com이 시각 T에 Auth Server에 로그인했다"를 증명.
- 클라이언트가 서명 검증 → 신원 확인.
1.5 OAuth 2.1 (초안, 2020+)
OAuth 2.0의 10년 운영 경험을 반영한 업데이트:
- Implicit flow 제거: 보안상 문제.
- Password grant 제거: 비밀번호를 client가 만진다는 자체가 반OAuth.
- PKCE 필수: 모든 flow에서 required.
- Redirect URI exact match: wildcard 금지.
- Refresh token rotation: 매 사용마다 교체.
아직 RFC로 확정되진 않았지만 많은 공급자가 이미 적용.
2. OAuth 2.0의 기본 개념
2.1 4대 역할
1. Resource Owner (RO): 자원을 소유한 사용자. 예: Gmail 사용자.
2. Client: 자원에 접근하려는 앱. 예: "Todo 앱이 내 Google Calendar 확인".
3. Resource Server (RS): API 서버. 예: calendar.google.com/api.
4. Authorization Server (AS): 토큰을 발급하는 서버. 예: accounts.google.com.
참고: RS와 AS는 같은 조직일 수도, 다를 수도 있다. Google은 같이 운영, Auth0는 AS만 제공.
2.2 Client 종류
Confidential Client: 비밀을 안전하게 보관 가능. 예: 서버 사이드 웹 앱. Client Secret 사용 가능.
Public Client: 비밀을 보관 못함. 예: 모바일 앱, SPA, CLI 도구. Secret 없음 → PKCE 등으로 보완.
과거에는 confidential/public 구분이 중요했지만, OAuth 2.1부터는 거의 모든 경우 PKCE 사용.
2.3 토큰 종류
Access Token: 리소스 서버에 접근할 때 사용. 보통 짧은 수명(5-60분).
Refresh Token: Access Token 재발급에 사용. 긴 수명(수일 ~ 수개월). AS에만 제출.
ID Token (OIDC만): 사용자 신원 정보. JWT. 클라이언트가 직접 파싱/검증.
2.4 Scope
권한 범위. AS에게 "이 토큰으로 뭘 할 수 있는가"를 선언.
scope=read:email write:calendar profile openid
read:email: 이메일 읽기.write:calendar: 캘린더 쓰기.profile: 사용자 프로필.openid: OIDC 사용을 의미. 이 scope 있으면 ID Token도 발급.
Scope는 AS가 정의. 표준은 없다(OIDC의 openid, profile, email 정도만 표준).
3. Authorization Code Flow
가장 중요한 flow. 웹 앱의 표준 로그인 흐름.
3.1 개요
User ←→ Client ←→ Authorization Server ←→ Resource Server
1. User: "앱으로 로그인해줘"
2. Client: User를 AS로 리다이렉트
3. AS: 로그인 화면 표시
4. User: 로그인 + 권한 승인
5. AS: Client에게 authorization code 전달 (redirect)
6. Client: code를 token으로 교환 (서버 간 직접 통신)
7. Client: access token으로 RS 호출
3.2 상세 흐름
Step 1: Authorization Request
Client가 user를 AS로 리다이렉트:
GET /authorize?
response_type=code
&client_id=abc123
&redirect_uri=https://app.example.com/callback
&scope=openid profile email
&state=xyz789 ← CSRF 방어
&code_challenge=<PKCE>
&code_challenge_method=S256
Step 2: User 승인
AS가 로그인 화면 + 권한 요청 화면 표시. 사용자가 "허용" 클릭.
Step 3: Authorization Response
AS가 사용자를 redirect_uri로 보냄:
https://app.example.com/callback?
code=AUTH_CODE_XYZ
&state=xyz789
Client는 state를 검증한다. 보낸 값과 받은 값이 일치하지 않으면 CSRF 공격 의심.
Step 4: Token Request
Client(서버 사이드)가 AS에 직접 요청:
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE_XYZ
&redirect_uri=https://app.example.com/callback
&client_id=abc123
&client_secret=secret456 ← confidential client만
&code_verifier=<PKCE> ← PKCE의 원본 값
Step 5: Token Response
AS가 토큰 발급:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "def456...",
"id_token": "eyJhbGciOiJSUzI1NiIs...", ← OIDC만
"scope": "openid profile email"
}
Step 6: API 호출
GET /api/userinfo
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
3.3 왜 이렇게 복잡한가
두 가지 주요 이유:
1. Front-channel / Back-channel 분리:
- Front-channel: 브라우저를 통함. 사용자 개입 가능.
- Back-channel: Client ↔ AS 직접. 사용자 안 봄.
Authorization code는 front-channel로 전달(리다이렉트). Token은 back-channel로만 전달(직접 HTTP). Token이 브라우저에 노출되지 않음.
2. User 상호작용 분리:
- 로그인과 동의는 사용자가 보는 화면 (AS).
- Token 사용은 서버가 하는 일 (Client).
이 분리로 Client는 사용자의 비밀번호를 영원히 알 필요 없다.
4. PKCE — 현대 OAuth의 필수
4.1 문제
모바일 앱이나 SPA는 client secret을 안전하게 저장할 수 없다. 앱 바이너리를 디컴파일하면 secret이 노출됨.
그래서 과거에는 "public client는 secret 없이 authorization code를 사용"했다. 하지만 취약점이 있다:
공격 시나리오:
- 사용자가 합법적인 앱에서 로그인 시작.
- 악성 앱이 같은 custom URL scheme (예:
myapp://)을 등록. - AS가 authorization code를 redirect로 반환 → 악성 앱이 가로챔.
- 악성 앱이 code를 token으로 교환 → 사용자 계정 탈취.
문제: code를 token으로 교환할 때 client가 누구인지 증명할 방법이 없다 (secret 없으므로).
4.2 PKCE의 해결
PKCE (Proof Key for Code Exchange, RFC 7636). 발음: "픽시(pixy)".
아이디어: client가 임의의 비밀 값을 만들어, (1) 해시를 미리 AS에 전달하고, (2) 토큰 요청 시 원본을 증명.
4.3 작동 방식
Step 1: Client가 code_verifier 생성 (43-128자 랜덤 문자열).
code_verifier = "M25iVXpKU3puUjFaYWg3T1NDTDQtcW1rOUY5ZXhFUnVqQkN4b2YwNGp..."
Step 2: code_challenge 계산.
code_challenge = BASE64URL(SHA256(code_verifier))
Step 3: Authorization request에 code_challenge 포함:
GET /authorize?
...
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
Step 4: AS가 code를 발급하면서 code_challenge를 기억.
Step 5: Token request에 원본 code_verifier 포함:
POST /token
...
&code_verifier=M25iVXpKU3puUjFaYWg3T1NDTDQtcW1rOUY5ZXhFUnVqQkN4b2YwNGp...
Step 6: AS가 검증:
SHA256(code_verifier) == code_challenge?
일치하면 token 발급, 아니면 거부.
4.4 공격 방어
악성 앱이 code를 가로챘다 해도 code_verifier를 모른다 (원본은 합법 앱의 메모리에만). Token 요청이 실패한다.
4.5 Plain vs S256
PKCE는 두 가지 challenge method 지원:
- plain:
code_challenge = code_verifier(해시 없음). - S256:
code_challenge = SHA256(code_verifier).
plain은 사용하지 말 것. 해시의 일방향성이 없으면 중간에서 code_challenge를 역으로 구해낼 수 있다.
4.6 PKCE는 모든 client에 필수
OAuth 2.1: 모든 client(confidential 포함)가 PKCE 사용. 이유:
- Defense in depth. Secret이 누출돼도 PKCE가 추가 방어선.
- 단순성: "client 종류에 따라 다른 보안"보다 "모두 같은 방식".
5. JWT — JSON Web Token
5.1 구조
JWT는 3부분으로 구성:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│ │ │
└─── Header ────────────────────────┘└──── Payload ────────────────────────────────────────┘└── Signature ──┘
각각 Base64URL 인코딩된 JSON.
Header:
{
"alg": "HS256",
"typ": "JWT",
"kid": "my-key-id"
}
alg: 서명 알고리즘.typ: 타입.kid: Key ID (키 롤오버 시 어떤 키로 서명됐는지).
Payload (Claims):
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1700000000,
"exp": 1700003600,
"iss": "https://example.com",
"aud": "my-api"
}
표준 claim:
sub: Subject (사용자 ID).iss: Issuer (토큰 발급자).aud: Audience (의도된 수신자).exp: Expiration.iat: Issued At.nbf: Not Before.jti: JWT ID (고유).
Signature:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
5.2 서명 알고리즘
- HS256: HMAC with SHA-256. 대칭 키 (공유 비밀).
- RS256: RSA-SHA256. 비대칭 (공개 키/개인 키).
- ES256: ECDSA P-256. 비대칭, 더 작은 서명.
- EdDSA: Ed25519. 가장 빠르고 안전.
- none: 서명 없음. 절대 프로덕션에서 사용 금지.
HS256은 서명자와 검증자가 같은 비밀을 가져야 한다. 보통 OIDC에서는 **비대칭(RS256/ES256)**을 쓴다 — AS가 서명, client/RS가 공개 키로 검증.
5.3 "alg: none" 취약점
초기 JWT 라이브러리들이 다음 공격에 취약했다:
{"alg":"none"}.{"admin":true}.
서명 없는 JWT. 라이브러리가 alg 검증 없이 받아들이면 누구나 위조 가능. 절대 alg: none 허용하지 말 것.
5.4 Key Confusion
다른 취약점: HS256과 RS256을 혼동.
Server가 RS256으로 검증하리라 예상하고 공개 키를 가짐. 공격자가 alg: HS256으로 바꾸고 공개 키를 비밀로 써서 HMAC 서명.
라이브러리가 alg를 신뢰하면 공개 키로 HMAC 검증을 시도 → 성공 → 위조 토큰 통과.
방어: 검증 시 예상하는 알고리즘을 하드코딩. alg 값을 신뢰하지 말 것.
5.5 JWT의 장단점
장점:
- Self-contained. DB 조회 없이 검증.
- Stateless. 서버가 세션 저장 불필요.
- 표준화.
단점:
- 취소 어려움. 만료 전까진 유효. 긴급 취소는 blacklist 필요 (stateful).
- 크기: 보통 수백~수 KB. 매 요청 전송.
- 민감 정보 노출: 서명만 되지 암호화 아님. Payload는 누구나 볼 수 있다.
실무 권장: 짧은 수명 access token (5-15분) + refresh token. 이러면 취소 지연을 허용 가능하게 제한.
6. OIDC — 인증 레이어
6.1 OAuth와의 차이
OAuth는 인가 (authorization). OIDC는 인증 (authentication).
OAuth만 썼을 때 문제:
- Client가 access token을 받았어도 "누가 로그인했는지" 모른다.
/userinfo같은 엔드포인트를 별도 호출해야 함.- 토큰 수명 관리 복잡.
OIDC의 해결: ID Token.
6.2 ID Token vs Access Token
| 항목 | ID Token | Access Token |
|---|---|---|
| 목적 | 사용자 신원 증명 | API 접근 |
| 수신자 | Client | Resource Server |
| 형식 | JWT (필수) | JWT 또는 opaque |
| 누가 검증 | Client | Resource Server |
| 어디 전달 | Client 메모리 (HTTP 요청에 전달 안 함) | Authorization 헤더 |
중요: ID Token을 API 호출에 사용하지 말 것. Access token을 ID 확인에 사용하지 말 것.
6.3 ID Token 예제
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"azp": "1234.apps.googleusercontent.com",
"aud": "1234.apps.googleusercontent.com",
"iat": 1700000000,
"exp": 1700003600,
"email": "user@example.com",
"email_verified": true,
"name": "John Doe",
"picture": "https://...",
"locale": "en"
}
주요 claim:
iss: Issuer URL.sub: Subject (사용자의 고유 ID. 이 값은 issuer 내에서 유일).aud: Audience. Client ID.azp: Authorized Party (Multi-audience일 때).nonce: CSRF 방어 (authorization request에서 전달).
6.4 Nonce
ID token 재생 공격을 방지:
Step 1: Client가 authorization request에 nonce 추가.
?nonce=random_string_123
Step 2: AS가 ID Token의 nonce claim에 같은 값 포함.
Step 3: Client가 ID Token 검증 시 nonce 일치 확인.
공격자가 옛 ID token을 재사용해도 nonce가 다르면 거부.
6.5 UserInfo Endpoint
OIDC 표준 엔드포인트. Access token을 제시하면 사용자 프로필을 반환:
GET /userinfo
Authorization: Bearer <access_token>
Response:
{
"sub": "110169484474386276334",
"name": "John Doe",
"email": "user@example.com",
...
}
ID Token에 없는 최신 정보가 필요할 때 사용.
7. OIDC Discovery와 JWKS
7.1 OIDC Discovery
Client가 AS의 모든 설정을 자동으로 알 수 있는 방법:
GET https://accounts.google.com/.well-known/openid-configuration
응답:
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"response_types_supported": ["code", "token", "id_token", "..."],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "email", "profile"],
...
}
클라이언트가 이걸 읽으면 수동 설정 필요 없음. 단 issuer 하나만 알면 모든 것 자동.
7.2 JWKS (JSON Web Key Set)
서명 공개 키를 공개하는 표준 형식:
{
"keys": [
{
"kid": "key-id-1",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "modulus base64url",
"e": "AQAB"
},
{
"kid": "key-id-2",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "another modulus",
"e": "AQAB"
}
]
}
검증자:
- JWT 헤더에서
kid읽기. - JWKS에서 해당
kid의 키 찾기. - 그 키로 서명 검증.
7.3 Key Rotation
JWKS의 중요한 기능. AS가 키를 주기적으로 교체:
Day 1: keys = [A]
Day 30: keys = [A, B] # B 추가, A로 서명된 기존 토큰도 검증 가능
Day 60: keys = [B] # A 제거, 모든 새 토큰은 B
Client는 JWKS를 주기적으로 갱신해야 한다. Cache-Control: max-age=3600 같은 힌트 참고.
7.4 JWKS 캐시 전략
매 JWT 검증마다 JWKS를 가져오면 느리고 AS에 부하. 캐시:
1. 초기 JWKS를 로드하고 메모리에 캐시.
2. 만료(예: 1시간) 후 재로드.
3. JWT의 kid가 캐시에 없으면 즉시 재로드 (key rotation 대응).
node-jwks-rsa, Okta JWT Verifier 같은 라이브러리가 이 로직을 제공.
8. Client Credentials Flow
서버 간 통신용. 사용자가 없는 경우.
8.1 용도
- 마이크로서비스 간 호출.
- 배치 작업.
- API 키 대체.
사용자 개입 없음. Client가 자기 자신을 증명하고 토큰 받음.
8.2 흐름
POST /token
Authorization: Basic <client_id:client_secret base64>
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&scope=read:users
응답:
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:users"
}
ID token 없음 (사용자가 없으므로).
8.3 주의사항
- Client secret 관리 필수. HashiCorp Vault, AWS Secrets Manager 등.
- mTLS 사용 권장 (secret 대신).
- 짧은 수명 (1시간 이하).
9. Device Code Flow
TV, 게임 콘솔, CLI 도구 등 키보드 입력이 어려운 기기용.
9.1 문제
스마트 TV에서 Netflix 로그인을 어떻게 할까? 비밀번호 입력이 불편. 스마트폰에서 로그인하고 TV에 권한을 넘겨주는 게 좋다.
9.2 흐름
Step 1: 기기(TV)가 AS에 요청:
POST /device/code
client_id=abc123
scope=openid profile
응답:
{
"device_code": "SOME_LONG_CODE",
"user_code": "WDJB-MJHT",
"verification_uri": "https://example.com/device",
"expires_in": 1800,
"interval": 5
}
Step 2: TV가 사용자에게 표시:
Go to https://example.com/device
Enter code: WDJB-MJHT
Step 3: 사용자가 스마트폰에서 URL 방문, 코드 입력, 로그인.
Step 4: TV가 동시에 polling:
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code
device_code=SOME_LONG_CODE
client_id=abc123
아직 사용자가 완료 안 했으면:
{"error": "authorization_pending"}
완료되면:
{
"access_token": "...",
"refresh_token": "..."
}
9.3 Security
- User code는 짧고 기억 쉬운 (
WDJB-MJHT). 대소문자 없음. - 짧은 수명 (수 분).
- rate limiting: 너무 자주 polling하면
slow_down에러.
Google TV, Xbox, PlayStation, GitHub CLI, gcloud auth login이 이 방식.
10. Refresh Token Flow
Access token 만료 시 재발급.
10.1 흐름
POST /token
grant_type=refresh_token
&refresh_token=OLD_REFRESH
&client_id=abc123
&client_secret=secret456 (confidential only)
응답:
{
"access_token": "NEW_ACCESS",
"refresh_token": "NEW_REFRESH", ← rotation 시
"expires_in": 3600
}
10.2 Rotation
OAuth 2.1 권장: refresh token을 매번 교체한다.
- 장점: 탈취된 refresh token이 한 번만 사용됨. 이후 사용 시 AS가 "이미 사용됨"을 감지 → 세션 무효화.
- 단점: client가 race condition 처리 필요 (동시에 refresh 요청 시).
10.3 Refresh Token 보관
- 웹 앱: HTTPOnly, Secure 쿠키.
- 모바일 앱: OS 키체인 (iOS Keychain, Android Keystore).
- SPA: 보관 불가. PKCE + 짧은 access token만 권장.
- 서버 간: 보통 refresh token 없이 매번 client_credentials.
11. 웹 앱의 실전 보안
11.1 Redirect URI 검증
AS는 등록된 redirect_uri와 정확히 일치하는 URL로만 리다이렉트해야 한다.
잘못된 설정:
Registered: https://app.example.com/callback
Received: https://app.example.com/callback/?a=b ← 거부해야
Registered: https://*.example.com/callback ← wildcard 금지 (OAuth 2.1)
11.2 State Parameter
CSRF 방어. 랜덤 값을 인증 요청에 포함, 응답에서 일치 확인:
// 요청 전
const state = generateRandomString(32);
sessionStorage.setItem('oauth_state', state);
window.location = `${auth_url}&state=${state}`;
// 응답 받은 후
const received_state = url.searchParams.get('state');
const expected_state = sessionStorage.getItem('oauth_state');
if (received_state !== expected_state) {
throw new Error('CSRF attempt!');
}
State 없으면 공격자가 자기 authorization code를 희생자에게 주입할 수 있다.
11.3 Token Storage
SPA에서 access token 저장:
- LocalStorage: XSS 취약.
- Memory only: 페이지 새로고침 시 사라짐.
- HTTPOnly Cookie: JavaScript 접근 불가, CSRF 방어는 별도.
현실: 완벽한 방법 없음. 최선은 짧은 수명의 access token + 메모리 저장 또는 Backend-for-Frontend 패턴 (서버가 토큰 보관, 브라우저는 세션 쿠키만).
11.4 Implicit Flow는 왜 죽었나
OAuth 2.0 원래 "public client용"으로 Implicit flow를 정의했다. 토큰을 URL fragment로 직접 반환:
https://app.example.com/callback#access_token=XYZ&expires_in=3600
문제:
- Access token이 브라우저 히스토리에 남음.
- Fragment가 referrer 통해 유출 가능.
- Refresh token 없음.
OAuth 2.1에서 폐기. Authorization Code + PKCE가 모든 client의 표준.
11.5 Open Redirect
redirect_uri가 엄격히 검증되지 않으면:
?redirect_uri=https://evil.com
사용자가 AS에 로그인 후 악성 사이트로 리다이렉트. Phishing의 발판.
방어: 허용된 redirect_uri 목록을 미리 등록, exact match.
12. Token Binding: DPoP와 mTLS
Bearer token의 문제: 토큰을 훔치면 아무 client가 사용 가능. "이 토큰은 이 client만 쓸 수 있다"는 보장이 없다.
12.1 mTLS Client Certificate
RFC 8705. Client가 TLS 연결에서 client certificate를 사용. AS는 토큰에 certificate thumbprint를 바인딩:
{
"cnf": {
"x5t#S256": "SHA256(client_cert)"
}
}
RS는 요청 시:
- TLS 연결의 client cert 확인.
- JWT의
cnf.x5t#S256이 현재 cert thumbprint와 일치? - 일치해야 허용.
토큰을 훔쳐도 client cert 없으면 사용 불가.
12.2 DPoP (Demonstrating Proof-of-Possession)
RFC 9449. mTLS 대안. client가 별도 공개 키로 각 요청을 서명.
Step 1: Client가 키 쌍 생성 (세션마다).
Step 2: Token request에 DPoP 헤더 추가:
POST /token
DPoP: <JWS signed with private key, contains public key in header>
...
Step 3: AS가 토큰에 DPoP thumbprint 바인딩:
{
"cnf": {
"jkt": "SHA256(DPoP public key)"
}
}
Step 4: API 호출 시 매번 DPoP JWS 생성:
GET /api/users
Authorization: DPoP <access_token>
DPoP: <new JWS for this request>
RS가 검증:
- DPoP JWS 서명이 유효한가?
- JWT의
cnf.jkt와 DPoP 공개 키 thumbprint 일치? - 요청의
htm/htuclaim이 실제 요청과 일치?
12.3 Bearer vs DPoP
| 항목 | Bearer | DPoP |
|---|---|---|
| 토큰 탈취 시 | 공격자가 사용 가능 | 사용 불가 (private key 없음) |
| Client 복잡도 | 낮음 | 높음 (매 요청 서명) |
| RS 복잡도 | 낮음 | 중간 (DPoP 검증) |
| 채택률 | 매우 높음 | 느림 |
DPoP는 중요도 높은 사용 사례(금융, 헬스케어)에서 채택 중. 일반 소비자 앱은 여전히 Bearer가 지배적.
13. FAPI — Financial-grade API
금융 기관의 "open banking"용 보안 프로파일.
13.1 목표
일반 OAuth로는 충분히 안전하지 않다. 금융 데이터 접근은 더 엄격한 보호 필요:
- 토큰 탈취 완전 차단.
- 요청 무결성 보장.
- 비가역적 거래 요청 보호.
13.2 FAPI 1.0 Read/Write
FAPI 1.0 Baseline (Read): 읽기 전용. PKCE + state/nonce 필수.
FAPI 1.0 Advanced (Read/Write): 전체. 추가:
- PAR (Pushed Authorization Request): authorization request를 미리 back-channel로 push.
- JARM (JWT Response Authorization Mode): authorization response도 JWT로 서명.
- mTLS 또는 DPoP: token binding 필수.
- Request Object: authorization request 파라미터를 JWT로 감쌈.
13.3 PAR
일반 OAuth:
GET /authorize?response_type=code&...&scope=...&state=...
파라미터가 URL에 노출. 큰 요청은 지저분. 중간자가 쉽게 조작.
PAR:
POST /par
{request object}
Response: { "request_uri": "urn:request:xyz" }
이후 authorization request는:
GET /authorize?client_id=...&request_uri=urn:request:xyz
훨씬 짧고 안전. 파라미터가 URL에 없음. 서명된 request object.
13.4 FAPI 2.0
2024년+ 진행 중. 더 단순하고 강력:
- PAR + DPoP 기본.
- mTLS 옵션.
- Sender-constrained tokens 강제.
영국, 호주, 브라질의 open banking이 FAPI 기반.
14. 실전 공급자 비교
14.1 Auth0
- 장점: 풍부한 기능, 훌륭한 문서, Rules/Actions로 커스터마이즈.
- 단점: 비쌈, vendor lock-in.
- 용도: B2C 앱, SaaS.
14.2 Okta
- 장점: 엔터프라이즈 최강, SSO, 거버넌스.
- 단점: 비쌈, 복잡.
- 용도: B2B, 대기업.
14.3 Keycloak
- 장점: 오픈소스, 자체 호스팅, 풍부한 기능.
- 단점: 운영 부담, 성능 튜닝 필요.
- 용도: 데이터 주권이 중요한 경우, 자체 호스팅 선호.
14.4 AWS Cognito
- 장점: AWS 통합, 저렴, 스케일.
- 단점: 문서 부족, 커스터마이즈 제한.
- 용도: AWS 기반 서비스.
14.5 Google / Apple / GitHub
Social login providers. 자체 AS 운영 않고 third-party에게 위임.
- Google Sign-In.
- Apple Sign-In (iOS 앱스토어 필수).
- GitHub OAuth (개발자 도구).
대부분 OIDC 지원. Discovery URL로 자동 설정.
14.6 Ory
오픈소스 identity 플랫폼:
- Hydra: OAuth/OIDC 서버.
- Kratos: 로그인/등록 시스템.
- Keto: 권한 관리.
Keycloak 대안. 각 컴포넌트가 독립, 더 클라우드 네이티브.
15. 흔한 보안 실수
15.1 Open Redirect
// BAD
const redirect_uri = req.query.redirect_uri;
res.redirect(oauth_url + '?redirect_uri=' + redirect_uri);
// 공격자: ?redirect_uri=https://evil.com
해결: whitelist.
15.2 Access Token으로 인증
// BAD
const token = verifyJWT(accessToken);
const user = db.find(token.sub); // Access token은 인증용이 아님
해결: OIDC ID token 또는 UserInfo endpoint 사용.
15.3 Refresh Token을 공개적으로
// BAD
localStorage.setItem('refresh_token', rt);
LocalStorage는 XSS로 털림. 해결: HTTPOnly cookie 또는 BFF 패턴.
15.4 Scope 과도 요청
scope=read write admin delete everything ← BAD
필요한 최소 scope만 요청. 사용자 신뢰와 권한 최소화.
15.5 JWT 검증 생략
// BAD
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.admin) { /* ... */ }
서명 검증 없이 payload를 신뢰 → 누구나 위조 가능.
해결: jsonwebtoken, jose 같은 검증 라이브러리 사용.
15.6 exp 검증 누락
만료 시간 검사 안 함 → 오래된 토큰도 작동. 반드시 exp 검증.
16. 디버깅 도구
16.1 jwt.io
JWT 디코더/검증기. 붙여넣으면 header/payload/signature 분석. 프로덕션 토큰 붙이지 말 것 (payload가 public).
16.2 Postman / Insomnia
OAuth flow 테스트 지원. "Get New Access Token" 버튼으로 authorization code flow 자동화.
16.3 OIDC Debugger
https://oidcdebugger.com — authorization request를 만들고 response를 검사.
16.4 MITM Proxy / Charles
HTTP 트래픽 가로채기. OAuth 흐름의 redirect, 토큰 교환 시각화.
16.5 OpenID Connect Conformance Test Suite
OIDC 구현체가 표준을 준수하는지 테스트. 공식 도구.
17. 학습 리소스
RFC:
- RFC 6749: OAuth 2.0 Framework.
- RFC 7636: PKCE.
- RFC 8705: mTLS client authentication.
- RFC 9449: DPoP.
- OpenID Connect Core 1.0.
책:
- "OAuth 2 in Action" — Justin Richer & Antonio Sanso (고전).
- "OAuth 2.0 Simplified" — Aaron Parecki (온라인 무료).
블로그/사이트:
- https://oauth.net — 모든 공식 문서와 확장.
- Justin Richer의 블로그 (OAuth WG 공저자).
- Aaron Parecki의 블로그 (IndieAuth 저자).
영상:
- OktaDev YouTube 채널.
- "OAuth & OIDC for Normal People" 강의.
18. 요약 — 한 장 정리
┌─────────────────────────────────────────────────────┐
│ OAuth 2.0 / OIDC Cheat Sheet │
├─────────────────────────────────────────────────────┤
│ 역할: │
│ Resource Owner (user) │
│ Client (app) │
│ Resource Server (API) │
│ Authorization Server (OAuth/OIDC 서버) │
│ │
│ 4대 Flow (2.1): │
│ 1. Authorization Code + PKCE (웹앱) │
│ 2. Client Credentials (서버간) │
│ 3. Device Code (TV/CLI) │
│ 4. Refresh Token │
│ │
│ 폐기된 Flow: │
│ - Implicit │
│ - Password │
│ │
│ 토큰: │
│ Access Token: API 접근 (짧은 수명) │
│ Refresh Token: 재발급 (긴 수명, 회전) │
│ ID Token: 사용자 신원 (JWT, OIDC만) │
│ │
│ JWT 구조: │
│ header.payload.signature │
│ Base64URL 인코딩 │
│ alg=HS256/RS256/ES256/EdDSA (never none) │
│ Claims: sub/iss/aud/exp/iat/nonce/jti │
│ │
│ PKCE: │
│ code_verifier (random) │
│ code_challenge = SHA256(verifier) │
│ Authorization req: challenge │
│ Token req: verifier │
│ S256만 사용 (plain 금지) │
│ │
│ OIDC Discovery: │
│ /.well-known/openid-configuration │
│ JWKS /.well-known/jwks.json │
│ Key rotation 지원 │
│ │
│ 보안: │
│ state (CSRF) │
│ nonce (replay) │
│ exact redirect_uri match │
│ HTTPS only │
│ Short-lived access tokens │
│ PKCE always │
│ │
│ Token Binding: │
│ Bearer: 기본 (취약) │
│ mTLS: client cert │
│ DPoP: 매 요청 서명 │
│ │
│ FAPI (금융급): │
│ PAR: pushed authorization request │
│ JARM: JWT authorization response │
│ Request Object │
│ mTLS 또는 DPoP 필수 │
│ │
│ 공급자: │
│ Auth0, Okta, Keycloak, AWS Cognito │
│ Google, Apple, GitHub (social) │
│ Ory (오픈소스) │
└─────────────────────────────────────────────────────┘
19. 퀴즈
Q1. OAuth 2.0과 OIDC의 차이는?
A. OAuth 2.0은 인가(authorization) 프로토콜이고 OIDC는 인증(authentication) 프로토콜. OAuth는 "이 앱이 내 Gmail을 읽도록 허용한다"는 위임. OIDC는 "이 사용자가 지금 로그인했다"는 신원 확인. 기술적으로 OIDC는 OAuth 2.0 위에 ID Token(JWT)을 추가한 레이어. OAuth만 쓰면 access token이 있어도 "누가 로그인했는지" 공식적으로 모른다 — OIDC의 ID Token이 이 간극을 메꾼다. OAuth를 인증에 쓰는 것은 흔한 실수로, 90%의 OAuth 버그가 여기서 나온다.
Q2. PKCE가 해결하는 공격은?
A. Authorization Code Interception Attack. 모바일 앱이나 SPA는 client secret을 안전하게 저장할 수 없는데, 이 경우 authorization code를 가로챈 악성 앱이 token으로 교환할 수 있었다(예: 악성 앱이 같은 custom URL scheme 등록). PKCE는 client가 랜덤 code_verifier를 만들어 code_challenge = SHA256(verifier)를 authorization request에 보내고, token request 시 원본 verifier를 증명. 중간에 code를 훔쳐도 verifier를 모르면 token 교환 불가. OAuth 2.1에서 모든 client(confidential 포함)에 필수가 됐다 — defense in depth.
Q3. JWT의 alg: none 취약점이란?
A. JWT 헤더의 alg 값이 none이면 서명이 없다는 뜻. 초기 JWT 라이브러리들은 이를 받아들였다. 공격자는 {"alg":"none"}.{"admin":true}.처럼 payload를 조작한 JWT를 만들면 서명 없이 통과 → 누구나 위조 가능. 방어: 검증 시 예상하는 알고리즘을 하드코딩해서 alg를 신뢰하지 말 것. jsonwebtoken 같은 라이브러리는 none을 기본 거부. 관련 취약점: HS/RS Key Confusion — 서버가 RS256 공개 키를 가지고 있을 때 공격자가 alg:HS256으로 바꾸고 공개 키를 HMAC 비밀로 사용해 위조.
Q4. OIDC Discovery와 JWKS가 해결하는 문제는?
A. 설정 자동화와 키 회전. Discovery(/.well-known/openid-configuration)는 AS의 모든 엔드포인트, 지원 알고리즘, scope를 하나의 JSON으로 노출 → client는 issuer URL 하나만 알면 모든 설정 자동. JWKS(/.well-known/jwks.json)는 서명 공개 키를 배포하는데, 각 키에 kid(key ID)가 있어서 AS가 여러 키를 동시에 노출할 수 있다. AS가 키를 교체할 때 [A, B] 상태로 두었다가 [B]로 전환하면 기존 토큰도 검증 가능 + 새 토큰은 B로. Client는 JWKS를 주기적으로 갱신해 key rotation에 자동 대응.
Q5. Implicit Flow가 폐기된 이유는?
A. Access token이 브라우저에 노출되는 보안 문제. Implicit flow는 토큰을 URL fragment(#access_token=...)로 직접 반환. 문제: (1) 브라우저 히스토리에 저장됨, (2) referrer 헤더로 유출 가능, (3) JavaScript로 접근 가능한 위치에 존재, (4) refresh token 없음. Authorization Code + PKCE가 훨씬 안전하고 refresh 가능. OAuth 2.0은 "SPA는 secret 저장 못 하니 Implicit를 쓰라"고 했지만, PKCE가 등장한 후 이 제약이 사라졌다. OAuth 2.1에서 공식 폐기. 현재 모든 주요 공급자가 Authorization Code + PKCE를 권장한다.
Q6. DPoP는 무엇을 해결하는가?
A. Bearer token의 탈취 위험. 일반 bearer token은 누구든 토큰 값을 가지면 사용 가능. 네트워크 중간이나 XSS로 탈취되면 끝. **DPoP(Demonstrating Proof-of-Possession)**는 client가 세션마다 키 쌍을 만들고, 매 API 요청마다 DPoP JWS를 private key로 서명해 전달. AS는 token 발급 시 DPoP 공개 키 thumbprint를 token에 바인딩(cnf.jkt). RS는 요청 시 (1) token의 jkt, (2) 현재 DPoP의 공개 키 해시, (3) 요청 URL/method까지 일치 확인. 탈취된 토큰도 private key 없으면 사용 불가. mTLS와 유사하지만 certificate 인프라 없이 구현 가능. FAPI, Open Banking에서 채택 중.
Q7. Refresh token rotation이란 무엇이며 왜 필요한가?
A. Refresh token을 사용할 때마다 새 값으로 교체하고 이전 값을 무효화하는 것. 왜 필요한가: Refresh token은 오래 유효(수일~수개월)하므로 탈취 위험이 크다. Rotation 없이는 탈취된 refresh token을 공격자가 무제한 사용 가능. Rotation이 있으면 공격자가 한 번 사용하면 합법 사용자의 refresh token이 무효화되고, 합법 사용자의 다음 refresh 시도는 실패 → 세션 탈취 감지. AS는 "이미 사용된 refresh token이 다시 제출됐다"를 감지하면 전체 토큰 패밀리를 무효화해 공격자와 희생자 모두의 세션 종료(확실한 차단). 단점: race condition(동시 refresh 요청), 네트워크 실패 시 토큰 상실 가능. OAuth 2.1에서 강력 권장.
이 글이 도움이 됐다면 다음 포스트도 확인해 보세요:
- "TLS/SSL Deep Dive" — HTTPS와 mTLS client certificate 배경.
- "DNS Deep Dive" — OIDC Discovery가 DNS 기반 구조를 어떻게 활용하는지.
- "API Versioning & Evolution" — OAuth scope 설계 원칙.
- "Distributed Tracing OpenTelemetry" — OAuth 토큰 전파와 관측성.
현재 단락 (1/655)
- **OAuth 2.0**은 **인가(authorization)** 프로토콜이지 인증(authentication)이 아니다. "이 앱이 내 Gmail을 읽게 허락한다"는 위임이지...