3단계
OAuth + state · PKCE
25 분
OAuth + state · PKCE
소셜 로그인은 보안 구현을 외부 IdP 에 위임하는 패턴. 두 개의 작은 디테일 (state · PKCE) 을 빠뜨리면 CSRF · 토큰 탈취에 노출.
1. OAuth 2.0 흐름 요약
1. 사용자 → "카카오로 로그인" 클릭
2. 앱 → Kakao 인증 URL 로 redirect (client_id · redirect_uri · state 포함)
3. Kakao → 사용자 승인
4. Kakao → redirect_uri?code=xxx&state=xxx
5. 앱 (서버) → Kakao 토큰 엔드포인트에 code 교환 → access_token
6. 앱 → access_token 으로 사용자 정보 조회
7. 앱 → 자체 세션 쿠키 발급
1 ~ 4 는 브라우저 redirect · 5 ~ 7 은 서버 대 서버.
2. state — CSRF 방어
사용자가 알지 못하는 사이에 공격자 계정으로 로그인되는 "Login CSRF" 를 막습니다.
// /api/auth/kakao (initiate)
export async function GET(req: NextRequest) {
const state = crypto.randomUUID();
const url = new URL("https://kauth.kakao.com/oauth/authorize");
url.searchParams.set("client_id", process.env.KAKAO_CLIENT_ID!);
url.searchParams.set("redirect_uri", process.env.KAKAO_REDIRECT_URI!);
url.searchParams.set("response_type", "code");
url.searchParams.set("state", state);
const res = NextResponse.redirect(url);
res.cookies.set("oauth_state", state, {
httpOnly: true, secure: true, sameSite: "lax", maxAge: 300,
});
return res;
}
// /api/auth/kakao/callback
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get("code");
const queryState = req.nextUrl.searchParams.get("state");
const cookieState = req.cookies.get("oauth_state")?.value;
if (!code || !queryState || cookieState !== queryState) {
return NextResponse.redirect(new URL("/login?e=csrf", req.url));
}
// ... code → token 교환
}
쿠키 state 와 query state 가 일치해야 진행.
3. PKCE — code intercept 방어
SPA · 모바일처럼 client_secret 을 안전하게 보관 못 하는 환경 에서 필수. 서버 기반 앱이어도 추가하면 안전.
import { createHash, randomBytes } from "crypto";
function base64url(b: Buffer) {
return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
const codeVerifier = base64url(randomBytes(32));
const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
// 인증 URL 에 추가
url.searchParams.set("code_challenge", codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
// 쿠키에 verifier 저장
res.cookies.set("oauth_verifier", codeVerifier, { httpOnly: true, maxAge: 300 });
callback 에서 token 교환 시 verifier 포함:
const body = new URLSearchParams({
grant_type: "authorization_code",
code, client_id, client_secret, redirect_uri,
code_verifier: verifier,
});
4. Kakao · Naver 차이
| provider | state 필수 | PKCE 지원 |
|---|---|---|
| Kakao | 권장 | 지원 |
| Naver | 필수 (공식 문서 명시) | 지원 |
| 권장 | 필수 (공식 권장) |
Naver state 검증 누락은 계정 연결 공격 가능성.
5. redirect_uri 화이트리스트
Kakao · Naver 개발자 콘솔에서 정확한 URL 만 허용 등록. 와일드카드 금지.
https://example.com/api/auth/kakao/callback ✅
https://example.com/* ❌ (일부 provider 허용하지만 피할 것)
6. 토큰 저장 위치
- access_token (Kakao/Naver) — DB 에 저장 시 AES-256-GCM 암호화
- 사용자 이메일 · ID —
users테이블 +auth_provider컬럼 - 세션 쿠키 — HTTP-only JWT (앞 장)
7. 계정 연결 · 이메일 중복
같은 이메일로 카카오 · 네이버 동시 가입 시?
- 옵션 A — 첫 번째 provider 만 허용
- 옵션 B — 동일 이메일은 자동 연결 (공격 벡터 존재)
- 옵션 C — 사용자에게 "이미 가입됨, 다른 provider 로 로그인" 안내
A 가 가장 안전. 편의를 위해 B 를 하면 이메일 인증이 끝난 provider 만 연결.
8. 로그아웃
export async function GET(req: Request) {
const res = NextResponse.redirect("/");
res.cookies.delete("session");
res.cookies.delete("refresh_token");
// OAuth provider 로그아웃은 별도 (Kakao/Naver 공식 URL)
return res;
}
앱 세션만 종료 vs provider 까지 로그아웃 — UX 정책 결정.
9. 자주 걸리는 자리
- state 검증 생략 — Login CSRF 취약
- redirect_uri 검증 허술 — open redirect
- access_token 평문 저장 — DB 유출 시 타 서비스 계정 탈취
- id_token 검증 생략 (OIDC) — 서명 검증 필수
- localStorage 에 토큰 — XSS 에 고스란히 노출
10. 운영 체크리스트
- 모든 provider state 검증
- PKCE 도입 (가능한 provider)
- redirect_uri 정확 매칭
- access_token DB 저장 시 암호화
- id_token · 서명 검증 (OIDC)
하고픈 말
OAuth 의 위험 요소는 대부분 state · redirect_uri · token 저장 세 자리에 집중. 이 셋만 정확하면 나머지는 provider SDK 가 처리.
Next
- 04-input-validation