- Published on
OAuth2 & JWT 완전 정복: 인증/인가의 모든 것 — 개발자를 위한 실전 가이드
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 1. 인증(Authentication) vs 인가(Authorization)
- 2. Session vs Token 기반 인증
- 3. OAuth2 프레임워크
- 4. OAuth2 플로우 심층 분석
- 5. JWT 심층 분석
- 6. Access Token + Refresh Token 전략
- 7. OpenID Connect (OIDC)
- 8. 실전 구현
- 9. 보안 취약점과 대응
- 10. 프로덕션 체크리스트
- 11. 면접 질문 15선
- 12. 퀴즈
- 참고 자료
- 마무리
들어가며
인증(Authentication)과 인가(Authorization)는 모든 웹 애플리케이션의 기초이자 가장 중요한 보안 요소입니다. OAuth2는 2012년 RFC 6749로 표준화된 이후 10년 넘게 사실상의 인가 표준으로 자리잡았고, JWT는 상태 비저장(stateless) 토큰의 대명사가 되었습니다.
그러나 많은 개발자들이 OAuth2 플로우를 혼동하거나, JWT의 보안 함정에 빠지곤 합니다. 이 글에서는 인증과 인가의 차이부터 OAuth2의 모든 플로우, JWT 심층 분석, 실전 구현(Spring Security, Next.js Auth.js, Go), 보안 취약점과 대응까지 체계적으로 다룹니다.
1. 인증(Authentication) vs 인가(Authorization)
핵심 차이
| 구분 | 인증 (Authentication) | 인가 (Authorization) |
|---|---|---|
| 질문 | "누구세요?" | "무엇을 할 수 있나요?" |
| 목적 | 사용자 신원 확인 | 접근 권한 확인 |
| 시점 | 먼저 수행 | 인증 후 수행 |
| 방법 | 비밀번호, 생체인식, OTP | 역할, 권한, 정책 |
| 프로토콜 | OIDC, SAML, WebAuthn | OAuth2, RBAC, ABAC |
| 비유 | 신분증 확인 | 출입 권한 확인 |
실생활 비유
공항 보안 체크:
1. 인증 (Authentication): 여권 검사
- "이 사람이 정말 김영주인가?"
- 여권(비밀번호)으로 신원 확인
2. 인가 (Authorization): 탑승권 확인
- "김영주가 이 비행기를 탈 수 있는가?"
- 탑승권(권한)으로 접근 범위 결정
- 비즈니스 라운지 접근? 일반 탑승?
자주 하는 실수
잘못된 이해:
"OAuth2는 인증 프로토콜이다" (X)
"OAuth2는 인가 프레임워크이다" (O)
OAuth2 자체는 "누구세요?"에 대한 답을 제공하지 않습니다.
인증이 필요하면 OAuth2 위에 OIDC(OpenID Connect)를 사용해야 합니다.
2. Session vs Token 기반 인증
Session 기반 인증
흐름:
1. 사용자 로그인 (ID/PW)
2. 서버가 세션 생성 (서버 메모리 또는 Redis)
3. 세션 ID를 쿠키로 클라이언트에 전송
4. 매 요청마다 쿠키로 세션 ID 전송
5. 서버가 세션 저장소에서 사용자 정보 조회
// Express + express-session 예시
const session = require('express-session')
const RedisStore = require('connect-redis').default
const redis = require('redis')
const redisClient = redis.createClient()
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // JavaScript 접근 차단
maxAge: 24 * 60 * 60 * 1000, // 24시간
sameSite: 'lax', // CSRF 방지
},
})
)
// 로그인
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body.email, req.body.password)
if (!user) return res.status(401).json({ error: 'Invalid credentials' })
req.session.userId = user.id
req.session.role = user.role
res.json({ message: 'Logged in successfully' })
})
// 인증 미들웨어
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' })
}
next()
}
Token 기반 인증
흐름:
1. 사용자 로그인 (ID/PW)
2. 서버가 JWT 생성 및 서명
3. JWT를 클라이언트에 전송
4. 매 요청마다 Authorization 헤더에 JWT 포함
5. 서버가 JWT 서명을 검증 (세션 저장소 불필요)
비교표
| 특성 | Session | Token (JWT) |
|---|---|---|
| 상태 | Stateful (서버에 세션 저장) | Stateless (토큰에 정보 포함) |
| 저장 위치 | 서버 (메모리/Redis) + 클라이언트 (쿠키) | 클라이언트 (쿠키/localStorage) |
| 확장성 | 세션 동기화 필요 (Sticky Session/Redis) | 뛰어남 (서명 검증만) |
| 보안 | 세션 하이재킹 주의 | 토큰 탈취, XSS 주의 |
| 크기 | 세션 ID만 (작음) | JWT 전체 (상대적으로 큼) |
| 무효화 | 즉시 가능 (세션 삭제) | 어려움 (만료까지 유효, 블랙리스트 필요) |
| 다중 서버 | Redis 등 공유 저장소 필요 | 서버 간 공유 불필요 |
| 모바일 | 쿠키 관리 복잡 | Authorization 헤더로 간편 |
| 마이크로서비스 | 세션 공유 문제 | JWT 전파 용이 |
언제 무엇을 쓸까?
Session을 쓸 때:
- 전통적인 서버 사이드 렌더링 웹앱 (MPA)
- 즉각적인 세션 무효화가 중요할 때
- 단일 서버 또는 적은 수의 서버
Token(JWT)을 쓸 때:
- SPA (React, Vue, Angular)
- 모바일 앱
- 마이크로서비스 아키텍처
- 서드파티 API 접근
- 서버리스 환경
3. OAuth2 프레임워크
4가지 역할
1. Resource Owner (리소스 소유자)
→ 사용자 본인. "내 데이터에 접근을 허가하는 주체"
2. Client (클라이언트)
→ 접근을 요청하는 애플리케이션. "사용자 데이터가 필요한 앱"
3. Authorization Server (인가 서버)
→ 사용자를 인증하고 토큰을 발급. "Google, GitHub, Keycloak 등"
4. Resource Server (리소스 서버)
→ 보호된 리소스를 호스팅. "API 서버"
OAuth2 엔드포인트
| 엔드포인트 | 목적 |
|---|---|
| /authorize | 사용자 인증 및 인가 코드 발급 |
| /token | 인가 코드를 Access Token으로 교환 |
| /revoke | 토큰 무효화 |
| /introspect | 토큰 유효성 검사 (RFC 7662) |
| /userinfo | OIDC 사용자 정보 조회 |
| /.well-known/openid-configuration | OIDC Discovery 문서 |
4. OAuth2 플로우 심층 분석
Authorization Code Flow (서버 애플리케이션용)
가장 기본적이고 안전한 플로우입니다. 서버 사이드 애플리케이션에 적합합니다.
단계별 흐름:
1. 클라이언트 → 인가 서버 (인가 요청)
GET /authorize?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=https://app.example.com/callback&
scope=read write&
state=random_csrf_token
2. 사용자가 인가 서버에서 로그인 및 동의
3. 인가 서버 → 클라이언트 (인가 코드 전달)
302 Redirect: https://app.example.com/callback?
code=AUTHORIZATION_CODE&
state=random_csrf_token
4. 클라이언트 → 인가 서버 (토큰 교환) [백채널, 서버-서버]
POST /token
grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=https://app.example.com/callback&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET
5. 인가 서버 → 클라이언트 (토큰 응답)
{
"access_token": "eyJhbGciOiJSUzI...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJl...",
"scope": "read write"
}
Authorization Code + PKCE (SPA, 모바일용)
SPA와 모바일 앱은 Client Secret을 안전하게 저장할 수 없으므로 PKCE(Proof Key for Code Exchange)를 사용합니다.
PKCE 추가 단계:
1. 클라이언트에서 code_verifier 생성 (43-128자 랜덤 문자열)
2. code_challenge = Base64URL(SHA256(code_verifier))
3. 인가 요청에 code_challenge + code_challenge_method=S256 포함
4. 토큰 교환 시 code_verifier 전송 (서버가 검증)
// PKCE 구현 (JavaScript)
function generateCodeVerifier() {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return base64URLEncode(array)
}
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return base64URLEncode(new Uint8Array(digest))
}
function base64URLEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// 사용
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
// 인가 요청
const authUrl =
`https://auth.example.com/authorize?` +
`response_type=code&` +
`client_id=CLIENT_ID&` +
`redirect_uri=https://app.example.com/callback&` +
`scope=openid profile email&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256&` +
`state=${generateState()}`
Client Credentials Flow (서버-서버)
사용자가 관여하지 않는 서버 간 통신에 사용합니다.
단계:
1. 클라이언트 → 인가 서버 (직접 토큰 요청)
POST /token
grant_type=client_credentials&
client_id=SERVICE_A_ID&
client_secret=SERVICE_A_SECRET&
scope=api.read
2. 인가 서버 → 클라이언트 (Access Token)
{
"access_token": "eyJhbGciOiJSUzI...",
"token_type": "Bearer",
"expires_in": 3600
}
(Refresh Token 없음 — 만료 시 다시 요청)
# Python으로 Client Credentials 요청
import requests
response = requests.post('https://auth.example.com/token', data={
'grant_type': 'client_credentials',
'client_id': 'service-a',
'client_secret': 'secret-value',
'scope': 'api.read api.write',
})
token = response.json()['access_token']
Device Code Flow (IoT, CLI)
키보드 입력이 어려운 장치(스마트 TV, CLI 도구)에 사용합니다.
단계:
1. 장치 → 인가 서버 (Device Code 요청)
POST /device/code
client_id=TV_APP_ID&scope=openid profile
2. 인가 서버 → 장치 (Device Code + User Code)
{
"device_code": "DEVICE_CODE_HERE",
"user_code": "ABCD-1234",
"verification_uri": "https://auth.example.com/device",
"expires_in": 1800,
"interval": 5
}
3. 장치가 화면에 표시: "https://auth.example.com/device 에서 ABCD-1234를 입력하세요"
4. 사용자가 다른 기기(폰, PC)에서 URL 접속 → 코드 입력 → 로그인 → 동의
5. 장치가 주기적으로 토큰 폴링
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code&
device_code=DEVICE_CODE_HERE&
client_id=TV_APP_ID
6. 인가 완료 후 Access Token 수신
Implicit Flow (더 이상 사용하지 않음)
Implicit Flow가 폐기된 이유:
1. Access Token이 URL Fragment에 노출 (#access_token=...)
2. 브라우저 히스토리에 토큰 기록
3. Referrer 헤더를 통한 토큰 유출
4. 토큰 탈취 공격에 취약
5. Refresh Token 사용 불가
대안: Authorization Code + PKCE
→ 브라우저 기반 앱도 이 방식을 사용해야 합니다.
→ OAuth 2.1 초안에서 Implicit Flow는 공식 제거됨
플로우 선택 가이드
| 애플리케이션 유형 | 권장 플로우 |
|---|---|
| 서버 사이드 웹앱 (Node, Spring, Django) | Authorization Code |
| SPA (React, Vue, Angular) | Authorization Code + PKCE |
| 모바일 앱 (iOS, Android) | Authorization Code + PKCE |
| 서버-서버 (마이크로서비스, 배치) | Client Credentials |
| IoT, 스마트 TV, CLI | Device Code |
| 레거시 (사용하지 말 것) |
5. JWT 심층 분석
JWT 구조
JWT는 점(.)으로 구분된 3개 부분으로 구성됩니다.
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.
POstGetfAytaZS82wHcjoTyoqhMyxXiWdR7Nn7A29DNSl0EiXLdwJ6xC6AfgZWF1bOsS_TuYI3OG85AmiExREkrS6tDfTQ2B3WXlrr-wp5AokiRbz3_oB4OxG-W9KcEEbDRcZc0nH3L7LzYptiy1PtAylQGxHTWZXtGz4ht0bAecBgmpdgXMguEIcoqPJ1n3pIWk_dUZegpqx0Lka21H6XxUTxiy8OcaarA8zdnPUnV6AmNP3ecFawIFYdvJB_cm-GvpCSbr8G8y_Mllj8f4x9nBH8pQux89_6gUY618iYv7tuPWBFfEbLxtF2pZS6YC1aSfLQxaOoaBSTqJoKn5L
[Header].[Payload].[Signature]
Header (Base64URL):
{
"alg": "RS256", // 서명 알고리즘
"typ": "JWT", // 토큰 타입
"kid": "key-id-1" // 키 ID (키 로테이션 시)
}
Payload (Base64URL):
{
"sub": "1234567890", // Subject (사용자 ID)
"name": "John Doe",
"email": "john@example.com",
"role": "admin",
"iss": "https://auth.example.com", // Issuer (발급자)
"aud": "https://api.example.com", // Audience (수신자)
"iat": 1516239022, // Issued At (발급 시간)
"exp": 1516242622, // Expiration (만료 시간)
"jti": "unique-token-id" // JWT ID (유일 식별자)
}
Signature:
RSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
서명 알고리즘 비교
| 알고리즘 | 타입 | 키 | 사용 시기 |
|---|---|---|---|
| HS256 | 대칭 | 하나의 비밀키 | 단일 서버, 내부 시스템 |
| RS256 | 비대칭 | 공개키 + 개인키 | 마이크로서비스, 외부 검증 |
| ES256 | 비대칭 (ECDSA) | 공개키 + 개인키 | 모바일, 성능 중요 시 |
| EdDSA | 비대칭 (Ed25519) | 공개키 + 개인키 | 최신, 고성능, 높은 보안 |
HS256 vs RS256:
HS256 (대칭):
- 서명: secretKey로 서명
- 검증: 같은 secretKey로 검증
- 문제: 모든 서비스가 secretKey를 알아야 함 → 유출 위험
RS256 (비대칭):
- 서명: privateKey로 서명 (인가 서버만 보유)
- 검증: publicKey로 검증 (누구나 가능)
- 장점: privateKey 유출 없이 다른 서비스가 토큰 검증 가능
- 권장: 마이크로서비스 환경
JWT 검증 코드
// Node.js JWT 검증 (jsonwebtoken)
const jwt = require('jsonwebtoken')
const jwksClient = require('jwks-rsa')
// JWKS(JSON Web Key Set)에서 공개키 가져오기
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
rateLimit: true,
})
function getSigningKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err)
callback(null, key.getPublicKey())
})
}
// JWT 검증 미들웨어
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' })
}
const token = authHeader.substring(7)
jwt.verify(
token,
getSigningKey,
{
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
},
(err, decoded) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' })
}
return res.status(401).json({ error: 'Invalid token' })
}
req.user = decoded
next()
}
)
}
JWT 주의사항
절대 하지 말 것:
1. JWT 페이로드에 비밀번호, 개인정보 저장 (Base64는 암호화가 아님!)
2. "alg": "none" 허용 (서명 없는 토큰 수락)
3. 서명 알고리즘 변경 공격 무시 (RS256 → HS256 다운그레이드)
4. exp 클레임 없이 토큰 발급 (영원히 유효한 토큰)
5. 토큰 크기를 무한히 키우기 (매 요청마다 전송됨)
반드시 할 것:
1. 항상 서명 검증 (알고리즘 고정)
2. exp, iss, aud 클레임 검증
3. HTTPS만 사용
4. 적절한 만료 시간 설정
5. 필요 최소한의 클레임만 포함
6. Access Token + Refresh Token 전략
토큰 생명주기
Access Token:
- 짧은 수명: 15분 ~ 1시간
- 매 API 요청에 사용
- 만료되면 Refresh Token으로 갱신
Refresh Token:
- 긴 수명: 7일 ~ 30일
- Access Token 갱신에만 사용
- 더 엄격하게 보호
Refresh Token 사용 흐름
1. 클라이언트가 Access Token으로 API 요청
2. 서버가 401 반환 (토큰 만료)
3. 클라이언트가 Refresh Token으로 새 Access Token 요청
POST /token
grant_type=refresh_token&
refresh_token=REFRESH_TOKEN_VALUE&
client_id=CLIENT_ID
4. 서버가 새 Access Token (+ 새 Refresh Token) 반환
5. 클라이언트가 새 Access Token으로 재요청
토큰 저장 위치
| 저장 위치 | XSS 안전 | CSRF 안전 | 권장 여부 |
|---|---|---|---|
| httpOnly Cookie | O (JS 접근 불가) | SameSite로 방지 | 권장 |
| localStorage | X (JS로 접근 가능) | O (자동 전송 안 함) | 비권장 |
| sessionStorage | X | O | 비권장 |
| 메모리 (JS 변수) | O (영구 저장 안 됨) | O | 보조적 사용 |
// 권장: httpOnly Cookie로 토큰 저장 (서버)
res.cookie('access_token', accessToken, {
httpOnly: true, // JavaScript 접근 불가
secure: true, // HTTPS에서만 전송
sameSite: 'lax', // CSRF 방지
maxAge: 15 * 60 * 1000, // 15분
path: '/',
})
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
path: '/api/auth/refresh', // refresh 엔드포인트에서만 전송
})
Refresh Token Rotation
일반 방식:
Refresh Token A → 새 Access Token 발급 (Refresh Token A 유지)
문제: Refresh Token 탈취 시 공격자가 계속 사용 가능
Rotation 방식:
Refresh Token A → 새 Access Token + 새 Refresh Token B 발급
(Refresh Token A 무효화)
이점: 탈취된 Refresh Token 사용 시 즉시 감지 가능
감지 메커니즘:
1. 이미 사용된 Refresh Token A로 요청
2. 서버: "이 토큰은 이미 교체됨 → 탈취 가능성!"
3. 해당 사용자의 모든 Refresh Token 무효화
4. 사용자에게 재로그인 요구
// Refresh Token Rotation 구현
async function refreshTokenHandler(req, res) {
const { refresh_token } = req.body
// DB에서 Refresh Token 조회
const storedToken = await db.refreshTokens.findOne({ token: refresh_token })
if (!storedToken) {
return res.status(401).json({ error: 'Invalid refresh token' })
}
// 이미 사용된 토큰인지 확인 (Reuse Detection)
if (storedToken.used) {
// 탈취 가능성 → 해당 사용자의 모든 토큰 무효화
await db.refreshTokens.deleteMany({ userId: storedToken.userId })
return res.status(401).json({ error: 'Token reuse detected. All sessions revoked.' })
}
// 기존 토큰을 사용됨으로 표시
await db.refreshTokens.updateOne({ token: refresh_token }, { used: true, usedAt: new Date() })
// 새 토큰 발급
const newAccessToken = generateAccessToken(storedToken.userId)
const newRefreshToken = generateRefreshToken()
await db.refreshTokens.insertOne({
token: newRefreshToken,
userId: storedToken.userId,
parentToken: refresh_token,
used: false,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
})
res.json({
access_token: newAccessToken,
refresh_token: newRefreshToken,
})
}
7. OpenID Connect (OIDC)
OIDC란?
OIDC는 OAuth2 위에 인증(Authentication) 레이어를 추가한 프로토콜입니다. OAuth2가 "무엇을 할 수 있는가"(인가)에 집중한다면, OIDC는 "누구인가"(인증)를 제공합니다.
ID Token
OIDC의 핵심은 ID Token(JWT)입니다.
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "my-client-id",
"exp": 1678886400,
"iat": 1678882800,
"nonce": "random-nonce-value",
"name": "John Doe",
"email": "john@gmail.com",
"email_verified": true,
"picture": "https://lh3.googleusercontent.com/photo.jpg"
}
OIDC Scopes
| Scope | 포함되는 클레임 |
|---|---|
| openid | sub (필수) |
| profile | name, family_name, given_name, picture, etc. |
| email, email_verified | |
| address | address |
| phone | phone_number, phone_number_verified |
OIDC Discovery
모든 OIDC 프로바이더는 Discovery 문서를 제공합니다:
GET https://accounts.google.com/.well-known/openid-configuration
응답에 포함되는 정보:
- issuer: 프로바이더 식별자
- authorization_endpoint: 인가 엔드포인트
- token_endpoint: 토큰 엔드포인트
- userinfo_endpoint: 사용자 정보 엔드포인트
- jwks_uri: 공개키 세트 URL
- scopes_supported: 지원 스코프
- response_types_supported: 지원 응답 타입
Access Token vs ID Token
| 특성 | Access Token | ID Token |
|---|---|---|
| 목적 | API 접근 인가 | 사용자 인증 |
| 수신자 | 리소스 서버 (API) | 클라이언트 앱 |
| 포함 정보 | scope, 권한 | 사용자 프로필 |
| 검증 | 리소스 서버에서 | 클라이언트에서 |
| API 전송 | O | X (절대 API에 보내지 말 것) |
8. 실전 구현
Spring Security OAuth2
// Spring Boot 3 + Spring Security 6 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("roles");
converter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri(
"https://auth.example.com/.well-known/jwks.json"
).build();
}
}
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
jwk-set-uri: https://auth.example.com/.well-known/jwks.json
Next.js Auth.js v5
// auth.ts (Auth.js v5 설정)
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Google({
clientId: process.env.GOOGLE_ID,
clientSecret: process.env.GOOGLE_SECRET,
}),
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const user = await verifyCredentials(
credentials.email as string,
credentials.password as string
)
return user || null
},
}),
],
callbacks: {
async jwt({ token, user, account }) {
if (user) {
token.id = user.id
token.role = user.role
}
if (account) {
token.accessToken = account.access_token
}
return token
},
async session({ session, token }) {
session.user.id = token.id as string
session.user.role = token.role as string
return session
},
},
pages: {
signIn: '/login',
error: '/auth/error',
},
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60, // 24시간
},
})
// middleware.ts
import { auth } from './auth'
export default auth((req) => {
const isLoggedIn = !!req.auth
const isProtected = req.nextUrl.pathname.startsWith('/dashboard')
if (isProtected && !isLoggedIn) {
return Response.redirect(new URL('/login', req.nextUrl))
}
})
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}
Go + OAuth2
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
var (
oauth2Config *oauth2.Config
oidcVerifier *oidc.IDTokenVerifier
)
func init() {
ctx := context.Background()
provider, _ := oidc.NewProvider(ctx, "https://accounts.google.com")
oauth2Config = &oauth2.Config{
ClientID: "CLIENT_ID",
ClientSecret: "CLIENT_SECRET",
RedirectURL: "http://localhost:8080/callback",
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
oidcVerifier = provider.Verifier(&oidc.Config{
ClientID: "CLIENT_ID",
})
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
state := generateState()
http.Redirect(w, r, oauth2Config.AuthCodeURL(state), http.StatusFound)
}
func handleCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
code := r.URL.Query().Get("code")
// 토큰 교환
token, err := oauth2Config.Exchange(ctx, code)
if err != nil {
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
return
}
// ID Token 추출 및 검증
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "No ID token", http.StatusInternalServerError)
return
}
idToken, err := oidcVerifier.Verify(ctx, rawIDToken)
if err != nil {
http.Error(w, "ID token verification failed", http.StatusInternalServerError)
return
}
// 클레임 추출
var claims struct {
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
idToken.Claims(&claims)
json.NewEncoder(w).Encode(claims)
}
func main() {
http.HandleFunc("/login", handleLogin)
http.HandleFunc("/callback", handleCallback)
fmt.Println("Server running on :8080")
http.ListenAndServe(":8080", nil)
}
9. 보안 취약점과 대응
CSRF (Cross-Site Request Forgery)
공격:
사용자가 로그인된 상태에서 악성 사이트 방문
→ 악성 사이트가 사용자의 쿠키를 이용해 API 요청
대응:
1. SameSite 쿠키 속성 설정 (Lax 또는 Strict)
2. CSRF 토큰 사용
3. Origin/Referer 헤더 검증
4. Authorization 헤더 사용 (쿠키 대신)
XSS를 통한 토큰 탈취
공격:
XSS 취약점을 통해 JavaScript 실행
→ localStorage의 토큰 탈취
대응:
1. httpOnly 쿠키에 토큰 저장 (JavaScript 접근 불가)
2. CSP(Content Security Policy) 헤더 설정
3. 입력 값 새니타이징
4. DOMPurify 등 XSS 방지 라이브러리 사용
Token Leakage
공격:
URL 파라미터에 토큰 포함
→ Referrer 헤더, 브라우저 히스토리, 서버 로그로 유출
대응:
1. 토큰을 URL에 포함하지 않기
2. Authorization 헤더 또는 httpOnly 쿠키 사용
3. Referrer-Policy 헤더 설정
Replay Attack
공격:
유효한 토큰을 가로채서 재사용
대응:
1. 짧은 토큰 만료 시간
2. jti(JWT ID) 클레임으로 일회성 확인
3. nonce 사용 (OIDC)
4. 토큰 바인딩 (DPoP — Demonstration of Proof-of-Possession)
JWT 알고리즘 혼동 공격
공격:
RS256 환경에서 공격자가 alg을 HS256으로 변경
→ 공개키를 HS256의 비밀키로 사용하여 서명
대응:
1. 서버에서 허용 알고리즘 고정 (algorithms: ['RS256'])
2. alg 클레임을 신뢰하지 않기
3. 최신 JWT 라이브러리 사용
OWASP Top 10 인증 관련
A07:2021 — Identification and Authentication Failures
체크리스트:
1. 약한 비밀번호 허용하지 않기
2. 무차별 대입 공격 방지 (계정 잠금, Rate Limiting)
3. 다중 인증(MFA) 지원
4. 세션 고정 공격 방지 (로그인 시 세션 ID 변경)
5. 안전한 비밀번호 저장 (bcrypt, Argon2)
6. 토큰/세션 만료 구현
7. 로그아웃 시 토큰/세션 완전 무효화
10. 프로덕션 체크리스트
보안 기본:
[ ] HTTPS 필수 (HTTP 리다이렉트)
[ ] 적절한 토큰 만료 시간 (Access: 15분, Refresh: 7일)
[ ] httpOnly + Secure + SameSite 쿠키 사용
[ ] CORS 올바르게 설정
토큰 관리:
[ ] Refresh Token Rotation 적용
[ ] 토큰 블랙리스트/무효화 메커니즘
[ ] JWT 서명 알고리즘 고정 (RS256/ES256)
[ ] 키 로테이션 계획 수립
입력 검증:
[ ] redirect_uri 화이트리스트 검증
[ ] state 파라미터로 CSRF 방지
[ ] PKCE 적용 (SPA, 모바일)
[ ] 입력 값 새니타이징
모니터링:
[ ] 로그인 실패 모니터링 및 알림
[ ] 비정상 토큰 사용 패턴 감지
[ ] Rate Limiting 적용
[ ] 감사 로그 기록
인프라:
[ ] 시크릿 관리 (AWS Secrets Manager, Vault)
[ ] 환경 변수로 민감 정보 관리
[ ] 정기적인 보안 감사
11. 면접 질문 15선
Q1. OAuth2에서 Authorization Code Flow가 Implicit Flow보다 안전한 이유는?
Authorization Code Flow는 인가 코드를 백채널(서버-서버)에서 토큰으로 교환하므로 토큰이 브라우저에 노출되지 않습니다. Implicit Flow는 토큰이 URL Fragment에 직접 노출되어 브라우저 히스토리, Referrer 헤더, 중간자 공격에 취약합니다.
Q2. JWT의 페이로드는 암호화되어 있나요?
아닙니다. JWT의 페이로드는 Base64URL로 인코딩만 되어 있어 누구나 디코딩할 수 있습니다. 서명은 무결성을 보장할 뿐 기밀성은 보장하지 않습니다. 페이로드 암호화가 필요하면 JWE(JSON Web Encryption)를 사용해야 합니다.
Q3. PKCE는 왜 필요한가요?
SPA와 모바일 앱은 Client Secret을 안전하게 저장할 수 없습니다. PKCE는 동적으로 생성된 code_verifier/code_challenge 쌍을 사용하여, 인가 코드를 가로채더라도 토큰을 교환할 수 없게 합니다. 인가 코드 가로채기 공격(Authorization Code Interception Attack)을 방지합니다.
Q4. Access Token의 적절한 만료 시간은?
일반적으로 15분에서 1시간입니다. 짧을수록 보안에 좋지만 사용자 경험이 나빠집니다(잦은 갱신). Refresh Token과 함께 사용하여 사용자 경험과 보안의 균형을 맞춥니다. 매우 민감한 작업(은행)은 5분, 일반 앱은 15~30분이 적당합니다.
Q5. Session 기반 vs Token 기반 인증의 차이점은?
Session 기반은 서버에 상태를 저장(Stateful)하고 세션 ID만 클라이언트에 전달합니다. 즉시 무효화 가능하지만 확장 시 세션 동기화가 필요합니다. Token 기반은 토큰에 정보를 포함(Stateless)하여 서버 저장소 불필요. 확장성이 뛰어나지만 즉시 무효화가 어렵습니다.
Q6. Refresh Token Rotation이란?
매번 Refresh Token 사용 시 새로운 Refresh Token을 발급하고 기존 것을 무효화하는 기법입니다. 탈취된 Refresh Token이 사용되면 이미 교체된 토큰이므로 감지할 수 있고, 해당 사용자의 모든 토큰을 무효화하여 피해를 최소화합니다.
Q7. OAuth2와 OIDC의 차이는?
OAuth2는 인가(Authorization) 프레임워크로 리소스 접근 권한을 부여합니다. OIDC는 OAuth2 위에 인증(Authentication) 레이어를 추가한 프로토콜로, ID Token을 통해 사용자 신원을 확인합니다. OAuth2로는 누구인지 알 수 없지만, OIDC로는 알 수 있습니다.
Q8. JWT를 localStorage에 저장하면 안 되는 이유는?
localStorage는 JavaScript로 접근 가능하여 XSS 공격 시 토큰이 탈취됩니다. httpOnly 쿠키에 저장하면 JavaScript에서 접근할 수 없어 XSS로부터 보호됩니다. SameSite 속성으로 CSRF도 방지할 수 있습니다.
Q9. HS256과 RS256의 차이와 사용 시기는?
HS256은 대칭키 알고리즘으로 하나의 비밀키로 서명과 검증을 합니다. 단일 서버 환경에 적합합니다. RS256은 비대칭키 알고리즘으로 개인키로 서명하고 공개키로 검증합니다. 마이크로서비스 환경에서 인가 서버만 개인키를 가지고, 다른 서비스는 공개키로 검증할 수 있어 안전합니다.
Q10. CORS와 OAuth2의 관계는?
SPA에서 OAuth2 토큰 엔드포인트에 요청 시 CORS 설정이 필요합니다. 인가 서버가 SPA의 Origin을 허용해야 합니다. 하지만 Authorization Code Flow에서 토큰 교환은 백엔드에서 하므로 CORS 문제가 없습니다. PKCE를 사용하는 SPA에서는 프론트에서 직접 토큰 요청 시 CORS 설정이 필수입니다.
Q11. ID Token을 API 서버에 전송해도 되나요?
안 됩니다. ID Token은 클라이언트 앱에서 사용자 신원을 확인하기 위한 것이고, API 접근에는 Access Token을 사용해야 합니다. ID Token의 audience(aud)는 클라이언트 앱이지 API 서버가 아닙니다. 혼용하면 보안 문제가 발생합니다.
Q12. Client Credentials Flow는 언제 사용하나요?
사용자가 관여하지 않는 서버 간 통신(Machine-to-Machine)에 사용합니다. 예: 마이크로서비스 간 API 호출, 배치 작업, 크론 작업. 사용자 컨텍스트가 없으므로 Refresh Token이 없고, 만료 시 다시 요청합니다.
Q13. JWT 알고리즘 혼동 공격이란?
RS256으로 서명된 환경에서 공격자가 JWT의 alg 헤더를 HS256으로 변경하고, 공개키(공개된 정보)를 HS256의 비밀키로 사용하여 유효한 서명을 만드는 공격입니다. 서버가 alg 클레임을 신뢰하면 위조된 토큰을 수락합니다. 대응: 서버에서 허용 알고리즘을 고정합니다.
Q14. 토큰 무효화(Revocation)는 어떻게 구현하나요?
JWT는 Stateless이므로 서버에서 직접 무효화할 수 없습니다. 방법: 1) 토큰 블랙리스트(Redis에 무효화된 jti 저장), 2) 짧은 만료 시간 + Refresh Token 무효화, 3) 토큰 버전(사용자별 token_version 필드, 버전 불일치 시 거부). 2번이 가장 실용적입니다.
Q15. DPoP(Demonstration of Proof-of-Possession)란?
토큰이 탈취되어도 공격자가 사용할 수 없도록, 토큰 사용 시 클라이언트가 개인키로 서명한 증명을 함께 제출하는 메커니즘입니다. Access Token과 DPoP Proof를 함께 제출하여 토큰 소유자만 사용할 수 있게 합니다. RFC 9449에 정의되어 있습니다.
12. 퀴즈
Q1. OAuth2의 4가지 역할은 무엇인가요?
Resource Owner(리소스 소유자 - 사용자), Client(클라이언트 - 앱), Authorization Server(인가 서버 - 토큰 발급), Resource Server(리소스 서버 - API)입니다.
Q2. JWT의 3가지 구성 요소는?
Header(알고리즘, 타입 정보), Payload(클레임 - 사용자 정보, 만료 시간 등), Signature(서명 - 무결성 검증)입니다. 각각 Base64URL로 인코딩되어 점(.)으로 구분됩니다.
Q3. Refresh Token은 왜 필요한가요?
Access Token의 만료 시간을 짧게 유지하면서도 사용자 경험을 해치지 않기 위해서입니다. Access Token이 만료되면 Refresh Token으로 새 Access Token을 발급받아, 사용자가 재로그인하지 않아도 됩니다.
Q4. OIDC의 ID Token과 Access Token의 차이는?
ID Token은 사용자 인증 정보를 담은 JWT로 클라이언트 앱에서 사용자 신원 확인에 사용합니다. Access Token은 API 접근 권한을 나타내며 리소스 서버에 전송합니다. ID Token을 API 서버에 보내면 안 됩니다.
Q5. state 파라미터의 역할은?
CSRF(Cross-Site Request Forgery) 공격을 방지합니다. 인가 요청 시 랜덤 값을 state로 포함하고, 콜백에서 같은 값이 반환되는지 확인합니다. 공격자가 위조한 인가 응답을 구분할 수 있습니다.
참고 자료
- RFC 6749: The OAuth 2.0 Authorization Framework
- RFC 7519: JSON Web Token (JWT)
- RFC 7636: Proof Key for Code Exchange (PKCE)
- RFC 7662: OAuth 2.0 Token Introspection
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- RFC 9457: Problem Details for HTTP APIs
- OpenID Connect Core 1.0
- OAuth 2.1 Draft
- OWASP Authentication Cheat Sheet
- Auth0 Documentation
- Keycloak Documentation
마무리
인증과 인가는 모든 애플리케이션 보안의 핵심입니다. OAuth2와 JWT는 강력하지만, 올바르게 구현하지 않으면 오히려 보안 취약점이 됩니다.
핵심을 다시 정리하면:
- OAuth2는 인가 프레임워크, 인증이 필요하면 OIDC를 사용
- Authorization Code + PKCE가 SPA/모바일의 표준
- JWT 페이로드는 암호화가 아닌 인코딩 — 민감 정보 저장 금지
- 토큰은 httpOnly 쿠키에 저장 — localStorage는 XSS에 취약
- Refresh Token Rotation 적용으로 탈취 감지
- 서명 알고리즘 고정 (RS256/ES256) — 알고리즘 혼동 공격 방지
- 짧은 Access Token 수명 + Refresh Token으로 균형
이 가이드를 기반으로 안전하고 사용자 친화적인 인증 시스템을 구축하세요.