개발팀장님이 구현한 OAuth 2.0 엿보기🐸
21 Apr 2026 | oauth2.0OAuth 2.0 Authorization Code + PKCE 정리
사내 user-service에 OAuth IdP 기능을 붙이는 PR을 구경하다가, 그동안 막연히 “카카오 로그인 같은 거”로만 알던 OAuth를 처음으로 제대로 뜯어봤다. 들여다보면서 막혔던 부분 위주로 정리.
먼저, 뭘 하려는 건가
우리 서비스(almondyoung)에 다른 서비스(다뷰) 가 붙어서, 다뷰 사용자가 “almondyoung으로 로그인” 버튼을 누르면 우리 계정으로 로그인되게 하는 것. 카카오 로그인에서 “카카오” 자리에 우리가 앉는 구조다. 우리 user-service가 IdP(Identity Provider) 역할을 한다.
일반 로그인과 뭐가 다른가
가장 먼저 잡고 가야 할 개념.
| 일반 로그인 | OAuth | |
|---|---|---|
| 주체 | 유저가 직접 로그인 | 다른 서비스가 유저 대신 |
| 왕복 | 1번 (POST /login) |
최소 2번 |
| 보내는 것 | ID/비밀번호 | code, verifier, clientId… |
| 목적 | 이 서비스에 로그인 | 다른 서비스에 내 계정으로 로그인 |
OAuth가 복잡해 보이는 이유는 “비밀번호를 제3자에게 절대 주지 않으면서 권한만 위임” 해야 하기 때문이다. 이 전제를 먼저 깔고 나면 나머지 설계가 납득된다.
등장 인물 3명
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ 다뷰 │───▶auth-web│───▶user-service│
│ (외부) │ │ (BFF) │ │ (IdP) │
└──────────┘ └──────────┘ └──────────────┘
- 다뷰: 로그인을 붙이고 싶은 외부 서비스
- auth-web: 우리 쪽 웹서버. 유저 브라우저를 받아주고 로그인 여부 체크
- user-service: 계정/토큰의 최종 권한자
PKCE라는 자물쇠
OAuth 공부하면서 제일 헷갈렸던 부분. 천천히 정리.
문제 상황
OAuth 흐름 중에 code라는 일회용 티켓을 유저 브라우저 URL로 주고받는 구간이 있다.
https://daview.com/callback?code=abc123
브라우저 주소창에 찍히면 히스토리, 프록시 로그, 악성 확장프로그램 등 여러 경로로 새어나갈 수 있다. 이 code를 공격자가 훔쳐도 토큰은 못 받게 해야 한다. 이걸 위한 장치가 PKCE.
어떻게 막나
값 두 개를 만든다.
verifier = 랜덤 원본 문자열
challenge = SHA256(verifier)
해시 함수는 일방향이라 challenge로는 verifier를 역산할 수 없다.
누가 뭘 쥐고 있나
| verifier | challenge | |
|---|---|---|
| 다뷰 서버 | 숨겨 보관 | 보냄 |
| user-service | 모름 | DB에 저장 |
| 공격자 | 없음 | URL에서 가로챔 |
공격자가 code를 훔치고, URL에 있던 challenge까지 본다 해도, verifier 원본은 다뷰 서버 안에만 있다. 나중에 토큰 교환 시 verifier 원본을 제시하라고 요구하는데, 공격자는 이걸 만들 수 없다.
전체 흐름
1차 왕복 — code 받기
[다뷰] [auth-web] [user-service]
│ │ │
│ ?challenge=... │ │
├─────────────────────▶│ │
│ │ 로그인 쿠키 확인 │
│ │ │
│ │ POST /internal/ │
│ │ issue-code │
│ ├─────────────────────▶│
│ │ ┌───────┴────────┐
│ │ │ DB 저장: │
│ │ │ - code │
│ │ │ - challenge │
│ │ │ (TTL 60s) │
│ │ └───────┬────────┘
│ │ { code } │
│ │◀─────────────────────┤
│ 302 redirect │ │
│ ?code=abc123 │ │
│◀─────────────────────┤ │
│ │ │
- 다뷰가 challenge를 보냄 (해시로 전환된 값)
- auth-web이 로그인 쿠키로 “이 유저 지금 로그인돼 있나” 확인
- user-service가 code를 만들어 DB에 저장 (challenge와 묶어서)
- 다뷰로 code를 리다이렉트
여기서 code가 URL에 노출된다. 그래서 TTL을 60초로 짧게 잡는다.
2차 왕복 — 토큰 받기
[다뷰] [user-service]
│ POST /oauth/token │
│ { code, verifier, clientId, secret } │
├────────────────────────────────────────────▶│
│ ┌────┴──────┐
│ │ 검증 : │
│ │ code유효 │
│ │ 만료전 │
│ │ PKCE: │
│ │ SHA(ver)│
│ │ ==DB │
│ │ challenge│
│ │ code소모 │
│ │ 토큰발급 │
│ └────┬──────┘
│ { accessToken, refreshToken } │
│◀────────────────────────────────────────────│
- 다뷰 서버가 직접 user-service를 호출 (브라우저 안 거침)
- code와 함께 verifier 원본을 보냄
- user-service는 verifier를 해시해서 DB의 challenge와 비교
-
일치하면 토큰 발급
왕복을 두 번 나누는 이유
브라우저 경유 서버-서버 직통
─────────── ────────────
[code] [access/refresh]
(PKCE로 방어) (노출 불가)
- code는 브라우저 URL을 거쳐야 하므로 유출 리스크가 있다 → PKCE로 방어
- 토큰은 노출되면 바로 계정 탈취 → 무조건 서버-서버로만
이 두 레이어를 분리하는 게 OAuth 설계의 핵심 아이디어다.
code를 보호하는 3중 방어
| 방어 | 내용 |
|---|---|
| TTL 60초 | 시간이 짧아서 훔쳐도 쓸 시간 없음 |
1회성 (consumedAt) |
한 번 쓰면 DB에 소모 표시, 재사용 불가 |
| PKCE | verifier 없으면 교환 실패 |
셋 다 뚫어야 공격 성공. 실질적으로 불가능에 가깝다.
핵심 검증 코드
user-service의 토큰 교환 로직.
async exchangeCodeForToken(input: TokenRequestDto) {
return this.inTx(async (tx) => {
const row = await this.repo.findUnconsumedCode(input.code, tx);
if (!row) throw new Error('invalid or already used code');
if (row.expiresAt < new Date()) throw new Error('code expired');
if (row.clientId !== input.clientId) throw new Error('client mismatch');
if (row.redirectUri !== input.redirectUri) throw new Error('redirect_uri mismatch');
// PKCE 검증
const computed = crypto
.createHash('sha256')
.update(input.codeVerifier)
.digest('base64url');
if (computed !== row.codeChallenge) {
throw new Error('PKCE verification failed');
}
await this.repo.markCodeConsumed(row.code, tx);
return this.mintTokenPair(row.userId, row.clientId, row.scope, undefined, tx);
});
}
검증 6개를 다 통과해야 토큰이 나간다. 그리고 code 소모 처리까지 하나의 트랜잭션으로 묶여 있다 — 동시 요청이 들어와도 한쪽만 성공하도록.
발급되는 토큰 두 종류
┌──────────────┬───────────────────┬──────────────────┬──────────┐
│ 종류 │ 형태 │ 저장 위치 │ 수명 │
├──────────────┼───────────────────┼──────────────────┼──────────┤
│ accessToken │ JWT (서명된 토큰) │ 없음 (stateless) │ 15분 │
├──────────────┼───────────────────┼──────────────────┼──────────┤
│ refreshToken │ 랜덤 문자열 │ DB │ 2주~90일 │
└──────────────┴───────────────────┴──────────────────┴──────────┘
- accessToken: JWT라서 서명만 검증하면 되고, DB 조회가 필요 없다 (stateless). 대신 유출 시 만료까지 막을 방법이 없어서 수명을 짧게.
- refreshToken: DB에 저장된 값이라 언제든 폐기 가능. 그래서 수명이 길어도 안전.
공부하고 나니 납득된 것들
- OAuth가 “복잡해 보이는” 이유 : 비밀번호 노출 없이 권한을 위임하려다 보니 불가피하게 단계가 늘어난 것
- code / token을 따로 두는 이유 : 브라우저 경유 vs 서버 직통을 분리하기 위함
- PKCE가 왜 “열쇠와 자물쇠” 비유로 설명되는지 : verifier(열쇠)는 다뷰만, challenge(자물쇠)는 IdP만 가짐
그럼에도 아직은 부족한 부분
- 개발팀장님이 작성하신 로직중에 이미 폐기된 refreshToken이 또 들어오면 “공격이다”로 판단하고 체인 전체를 BFS로 훑어서 다 폐기하는 로직이있는데 이해가 좀 부족함
wndtlr1024