Split View: OAuth2 & JWT 완전 정복: 인증/인가의 모든 것 — 개발자를 위한 실전 가이드
OAuth2 & JWT 완전 정복: 인증/인가의 모든 것 — 개발자를 위한 실전 가이드
- 들어가며
- 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으로 균형
이 가이드를 기반으로 안전하고 사용자 친화적인 인증 시스템을 구축하세요.
OAuth2 & JWT Complete Guide: Everything About Authentication and Authorization for Developers
- Introduction
- 1. Authentication vs Authorization
- 2. Session vs Token-Based Authentication
- 3. OAuth2 Framework
- 4. OAuth2 Flows Deep Dive
- 5. JWT Deep Dive
- 6. Access Token + Refresh Token Strategy
- 7. OpenID Connect (OIDC)
- 8. Practical Implementation
- 9. Security Vulnerabilities and Defenses
- 10. Production Checklist
- 11. Interview Questions
- 12. Quiz
- References
- Conclusion
Introduction
Authentication and Authorization are the foundation and most critical security components of every web application. OAuth2 has been the de facto authorization standard for over 10 years since being standardized as RFC 6749 in 2012, and JWT has become synonymous with stateless tokens.
However, many developers confuse OAuth2 flows or fall into JWT security pitfalls. This article systematically covers everything from the difference between authentication and authorization, all OAuth2 flows, JWT deep dive, practical implementations (Spring Security, Next.js Auth.js, Go), to security vulnerabilities and defenses.
1. Authentication vs Authorization
Key Differences
| Aspect | Authentication | Authorization |
|---|---|---|
| Question | "Who are you?" | "What can you do?" |
| Purpose | Verify user identity | Verify access rights |
| Timing | Performed first | After authentication |
| Methods | Password, Biometrics, OTP | Roles, Permissions, Policies |
| Protocols | OIDC, SAML, WebAuthn | OAuth2, RBAC, ABAC |
| Analogy | Checking ID | Checking access badge |
Real-World Analogy
Airport Security Check:
1. Authentication: Passport verification
- "Is this person really John Doe?"
- Identity verified with passport (password)
2. Authorization: Boarding pass check
- "Can John Doe board this flight?"
- Access scope determined by boarding pass (permissions)
- Business lounge access? Economy boarding?
Common Misconception
Incorrect: "OAuth2 is an authentication protocol" (X)
Correct: "OAuth2 is an authorization framework" (O)
OAuth2 itself does not answer "Who are you?"
If you need authentication, use OIDC (OpenID Connect) on top of OAuth2.
2. Session vs Token-Based Authentication
Session-Based Authentication
Flow:
1. User logs in (ID/PW)
2. Server creates session (server memory or Redis)
3. Session ID sent to client as cookie
4. Every request includes session ID via cookie
5. Server looks up user info from session store
// Express + express-session example
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, // Block JavaScript access
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'lax', // CSRF prevention
},
})
)
// Login
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' })
})
// Auth middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' })
}
next()
}
Token-Based Authentication
Flow:
1. User logs in (ID/PW)
2. Server creates and signs JWT
3. JWT sent to client
4. Every request includes JWT in Authorization header
5. Server verifies JWT signature (no session store needed)
Comparison
| Feature | Session | Token (JWT) |
|---|---|---|
| State | Stateful (session stored on server) | Stateless (info in token) |
| Storage | Server (memory/Redis) + Client (cookie) | Client (cookie/localStorage) |
| Scalability | Session sync needed (Sticky Session/Redis) | Excellent (signature verification only) |
| Security | Session hijacking risk | Token theft, XSS risk |
| Size | Session ID only (small) | Full JWT (relatively large) |
| Revocation | Instant (delete session) | Difficult (valid until expiry, blacklist needed) |
| Multi-server | Shared store (Redis) needed | No sharing needed |
| Mobile | Cookie management complex | Simple with Authorization header |
| Microservices | Session sharing problem | JWT propagation easy |
When to Use What?
Use Sessions when:
- Traditional server-side rendered web apps (MPA)
- Instant session revocation is critical
- Single server or few servers
Use Tokens (JWT) when:
- SPA (React, Vue, Angular)
- Mobile apps
- Microservices architecture
- Third-party API access
- Serverless environments
3. OAuth2 Framework
Four Roles
1. Resource Owner
-> The user themselves. "The entity that grants access to my data"
2. Client
-> The application requesting access. "The app that needs user data"
3. Authorization Server
-> Authenticates users and issues tokens. "Google, GitHub, Keycloak, etc."
4. Resource Server
-> Hosts protected resources. "The API server"
OAuth2 Endpoints
| Endpoint | Purpose |
|---|---|
| /authorize | User authentication and authorization code issuance |
| /token | Exchange authorization code for Access Token |
| /revoke | Token revocation |
| /introspect | Token validity check (RFC 7662) |
| /userinfo | OIDC user information retrieval |
| /.well-known/openid-configuration | OIDC Discovery document |
4. OAuth2 Flows Deep Dive
Authorization Code Flow (For Server Applications)
The most fundamental and secure flow. Suitable for server-side applications.
Step-by-step flow:
1. Client -> Auth Server (Authorization Request)
GET /authorize?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=https://app.example.com/callback&
scope=read write&
state=random_csrf_token
2. User logs in and consents at Authorization Server
3. Auth Server -> Client (Authorization Code)
302 Redirect: https://app.example.com/callback?
code=AUTHORIZATION_CODE&
state=random_csrf_token
4. Client -> Auth Server (Token Exchange) [back channel, server-to-server]
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. Auth Server -> Client (Token Response)
{
"access_token": "eyJhbGciOiJSUzI...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJl...",
"scope": "read write"
}
Authorization Code + PKCE (For SPA, Mobile)
SPAs and mobile apps cannot securely store Client Secrets, so they use PKCE (Proof Key for Code Exchange).
Additional PKCE steps:
1. Client generates code_verifier (43-128 char random string)
2. code_challenge = Base64URL(SHA256(code_verifier))
3. Include code_challenge + code_challenge_method=S256 in auth request
4. Send code_verifier during token exchange (server verifies)
// PKCE Implementation (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, '')
}
// Usage
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
// Authorization request
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 (Server-to-Server)
Used for server-to-server communication without user involvement.
Steps:
1. Client -> Auth Server (Direct Token Request)
POST /token
grant_type=client_credentials&
client_id=SERVICE_A_ID&
client_secret=SERVICE_A_SECRET&
scope=api.read
2. Auth Server -> Client (Access Token)
{
"access_token": "eyJhbGciOiJSUzI...",
"token_type": "Bearer",
"expires_in": 3600
}
(No Refresh Token -- request again on expiry)
# Python Client Credentials request
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)
Used for devices where keyboard input is difficult (smart TV, CLI tools).
Steps:
1. Device -> Auth Server (Device Code Request)
POST /device/code
client_id=TV_APP_ID&scope=openid profile
2. Auth Server -> Device (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. Device displays: "Go to https://auth.example.com/device and enter ABCD-1234"
4. User visits URL on another device (phone, PC) -> enters code -> logs in -> consents
5. Device polls for token periodically
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code&
device_code=DEVICE_CODE_HERE&
client_id=TV_APP_ID
6. Receives Access Token after authorization
Implicit Flow (Deprecated)
Why Implicit Flow was deprecated:
1. Access Token exposed in URL Fragment (#access_token=...)
2. Token recorded in browser history
3. Token leakage via Referrer headers
4. Vulnerable to token theft attacks
5. Cannot use Refresh Tokens
Alternative: Authorization Code + PKCE
-> Browser-based apps should use this approach.
-> Implicit Flow officially removed in OAuth 2.1 draft
Flow Selection Guide
| Application Type | Recommended Flow |
|---|---|
| Server-side Web Apps (Node, Spring, Django) | Authorization Code |
| SPA (React, Vue, Angular) | Authorization Code + PKCE |
| Mobile Apps (iOS, Android) | Authorization Code + PKCE |
| Server-to-Server (Microservices, Batch) | Client Credentials |
| IoT, Smart TV, CLI | Device Code |
| Legacy (Do not use) |
5. JWT Deep Dive
JWT Structure
JWT consists of 3 parts separated by dots (.).
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.
POstGetfAytaZS82wHcjoTyoqhMyxXiWdR7Nn7A29DNSl0EiXLdwJ6xC6AfgZWF1bOsS...
[Header].[Payload].[Signature]
Header (Base64URL):
{
"alg": "RS256", // Signing algorithm
"typ": "JWT", // Token type
"kid": "key-id-1" // Key ID (for key rotation)
}
Payload (Base64URL):
{
"sub": "1234567890", // Subject (User 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 (unique identifier)
}
Signature:
RSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
Signing Algorithm Comparison
| Algorithm | Type | Key | When to Use |
|---|---|---|---|
| HS256 | Symmetric | Single secret key | Single server, internal systems |
| RS256 | Asymmetric | Public + Private key | Microservices, external verification |
| ES256 | Asymmetric (ECDSA) | Public + Private key | Mobile, performance-critical |
| EdDSA | Asymmetric (Ed25519) | Public + Private key | Latest, high performance, high security |
HS256 vs RS256:
HS256 (Symmetric):
- Sign: Sign with secretKey
- Verify: Verify with same secretKey
- Problem: All services must know secretKey -> leak risk
RS256 (Asymmetric):
- Sign: Sign with privateKey (only auth server has it)
- Verify: Verify with publicKey (anyone can)
- Advantage: Other services can verify tokens without privateKey exposure
- Recommended: Microservices environments
JWT Verification Code
// Node.js JWT verification (jsonwebtoken)
const jwt = require('jsonwebtoken')
const jwksClient = require('jwks-rsa')
// Get public key from 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 verification middleware
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 Pitfalls
Never do:
1. Store passwords or PII in JWT payload (Base64 is NOT encryption!)
2. Allow "alg": "none" (accept unsigned tokens)
3. Ignore algorithm confusion attacks (RS256 -> HS256 downgrade)
4. Issue tokens without exp claim (eternally valid tokens)
5. Grow token size indefinitely (sent with every request)
Always do:
1. Always verify signatures (fix algorithm)
2. Validate exp, iss, aud claims
3. Use HTTPS only
4. Set appropriate expiration times
5. Include only minimum necessary claims
6. Access Token + Refresh Token Strategy
Token Lifecycle
Access Token:
- Short lifespan: 15 min to 1 hour
- Used for every API request
- Renewed with Refresh Token when expired
Refresh Token:
- Long lifespan: 7 days to 30 days
- Used only to renew Access Tokens
- Protected more strictly
Refresh Token Flow
1. Client makes API request with Access Token
2. Server returns 401 (token expired)
3. Client requests new Access Token with Refresh Token
POST /token
grant_type=refresh_token&
refresh_token=REFRESH_TOKEN_VALUE&
client_id=CLIENT_ID
4. Server returns new Access Token (+ new Refresh Token)
5. Client retries with new Access Token
Token Storage Locations
| Storage Location | XSS Safe | CSRF Safe | Recommended |
|---|---|---|---|
| httpOnly Cookie | Yes (no JS access) | SameSite prevents | Recommended |
| localStorage | No (JS accessible) | Yes (no auto-send) | Not recommended |
| sessionStorage | No | Yes | Not recommended |
| Memory (JS variable) | Yes (not persisted) | Yes | Supplementary use |
// Recommended: Store tokens in httpOnly cookies (server-side)
res.cookie('access_token', accessToken, {
httpOnly: true, // No JavaScript access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF prevention
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
})
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh', // Only sent to refresh endpoint
})
Refresh Token Rotation
Standard approach:
Refresh Token A -> New Access Token issued (Refresh Token A kept)
Problem: If Refresh Token is stolen, attacker can keep using it
Rotation approach:
Refresh Token A -> New Access Token + New Refresh Token B issued
(Refresh Token A invalidated)
Benefit: Immediate detection if stolen Refresh Token is used
Detection mechanism:
1. Request comes with already-used Refresh Token A
2. Server: "This token was already rotated -> possible theft!"
3. Invalidate ALL Refresh Tokens for that user
4. Require user to re-login
// Refresh Token Rotation implementation
async function refreshTokenHandler(req, res) {
const { refresh_token } = req.body
// Look up Refresh Token in DB
const storedToken = await db.refreshTokens.findOne({ token: refresh_token })
if (!storedToken) {
return res.status(401).json({ error: 'Invalid refresh token' })
}
// Check if token was already used (Reuse Detection)
if (storedToken.used) {
// Possible theft -> invalidate all tokens for user
await db.refreshTokens.deleteMany({ userId: storedToken.userId })
return res.status(401).json({ error: 'Token reuse detected. All sessions revoked.' })
}
// Mark existing token as used
await db.refreshTokens.updateOne({ token: refresh_token }, { used: true, usedAt: new Date() })
// Issue new tokens
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)
What is OIDC?
OIDC adds an authentication layer on top of OAuth2. While OAuth2 focuses on "What can you do?" (authorization), OIDC provides "Who are you?" (authentication).
ID Token
The core of OIDC is the 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 | Included Claims |
|---|---|
| openid | sub (required) |
| profile | name, family_name, given_name, picture, etc. |
| email, email_verified | |
| address | address |
| phone | phone_number, phone_number_verified |
OIDC Discovery
All OIDC providers serve a Discovery document:
GET https://accounts.google.com/.well-known/openid-configuration
Included information:
- issuer: Provider identifier
- authorization_endpoint: Authorization endpoint
- token_endpoint: Token endpoint
- userinfo_endpoint: User info endpoint
- jwks_uri: Public key set URL
- scopes_supported: Supported scopes
- response_types_supported: Supported response types
Access Token vs ID Token
| Feature | Access Token | ID Token |
|---|---|---|
| Purpose | API access authorization | User authentication |
| Recipient | Resource Server (API) | Client App |
| Contains | Scope, permissions | User profile |
| Validated by | Resource Server | Client |
| Send to API | Yes | No (never send ID Token to API) |
8. Practical Implementation
Spring Security OAuth2
// Spring Boot 3 + Spring Security 6 Configuration
@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 configuration)
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 hours
},
})
// 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 exchange
token, err := oauth2Config.Exchange(ctx, code)
if err != nil {
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
return
}
// Extract and verify 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
}
// Extract claims
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. Security Vulnerabilities and Defenses
CSRF (Cross-Site Request Forgery)
Attack:
User visits malicious site while logged in
-> Malicious site uses user cookies to make API requests
Defense:
1. Set SameSite cookie attribute (Lax or Strict)
2. Use CSRF tokens
3. Validate Origin/Referer headers
4. Use Authorization header (instead of cookies)
XSS Token Theft
Attack:
Execute JavaScript through XSS vulnerability
-> Steal tokens from localStorage
Defense:
1. Store tokens in httpOnly cookies (no JavaScript access)
2. Set CSP (Content Security Policy) headers
3. Sanitize input values
4. Use XSS prevention libraries like DOMPurify
Token Leakage
Attack:
Include token in URL parameters
-> Leaked via Referrer headers, browser history, server logs
Defense:
1. Never include tokens in URLs
2. Use Authorization header or httpOnly cookies
3. Set Referrer-Policy header
Replay Attack
Attack:
Intercept a valid token and reuse it
Defense:
1. Short token expiration time
2. Use jti (JWT ID) claim for one-time verification
3. Use nonce (OIDC)
4. Token binding (DPoP -- Demonstration of Proof-of-Possession)
JWT Algorithm Confusion Attack
Attack:
In RS256 environment, attacker changes alg to HS256
-> Uses public key as HS256 secret to forge signatures
Defense:
1. Fix allowed algorithms on server (algorithms: ['RS256'])
2. Never trust the alg claim
3. Use latest JWT libraries
OWASP Top 10 Authentication Related
A07:2021 -- Identification and Authentication Failures
Checklist:
1. Don't allow weak passwords
2. Prevent brute force attacks (account lockout, Rate Limiting)
3. Support Multi-Factor Authentication (MFA)
4. Prevent session fixation (change session ID on login)
5. Secure password storage (bcrypt, Argon2)
6. Implement token/session expiration
7. Fully invalidate tokens/sessions on logout
10. Production Checklist
Security Basics:
[ ] HTTPS required (HTTP redirect)
[ ] Appropriate token expiration (Access: 15min, Refresh: 7 days)
[ ] httpOnly + Secure + SameSite cookies
[ ] CORS correctly configured
Token Management:
[ ] Refresh Token Rotation applied
[ ] Token blacklist/revocation mechanism
[ ] JWT signing algorithm fixed (RS256/ES256)
[ ] Key rotation plan established
Input Validation:
[ ] redirect_uri whitelist validation
[ ] state parameter for CSRF prevention
[ ] PKCE applied (SPA, mobile)
[ ] Input sanitization
Monitoring:
[ ] Login failure monitoring and alerts
[ ] Anomalous token usage pattern detection
[ ] Rate Limiting applied
[ ] Audit logging
Infrastructure:
[ ] Secret management (AWS Secrets Manager, Vault)
[ ] Sensitive info in environment variables
[ ] Regular security audits
11. Interview Questions
Q1. Why is Authorization Code Flow safer than Implicit Flow?
Authorization Code Flow exchanges the authorization code for tokens via back channel (server-to-server), so tokens are never exposed in the browser. Implicit Flow directly exposes tokens in URL Fragments, making them vulnerable to browser history, Referrer headers, and man-in-the-middle attacks.
Q2. Is the JWT payload encrypted?
No. The JWT payload is only Base64URL encoded, meaning anyone can decode it. The signature guarantees integrity, not confidentiality. If payload encryption is needed, use JWE (JSON Web Encryption).
Q3. Why is PKCE necessary?
SPAs and mobile apps cannot securely store Client Secrets. PKCE uses dynamically generated code_verifier/code_challenge pairs so that even if the authorization code is intercepted, it cannot be exchanged for tokens. It prevents Authorization Code Interception Attacks.
Q4. What is an appropriate Access Token expiration time?
Typically 15 minutes to 1 hour. Shorter is better for security but worse for user experience (frequent renewals). Use with Refresh Tokens to balance UX and security. Highly sensitive operations (banking) should use 5 minutes; general apps should use 15-30 minutes.
Q5. What are the differences between Session-based and Token-based auth?
Session-based stores state on the server (Stateful) and only passes a session ID to the client. Instant revocation is possible but session sync is needed for scaling. Token-based includes info in the token (Stateless) requiring no server storage. Excellent scalability but instant revocation is difficult.
Q6. What is Refresh Token Rotation?
A technique where each Refresh Token use issues a new Refresh Token and invalidates the old one. If a stolen Refresh Token is used, it has already been rotated so it can be detected, and all tokens for that user can be invalidated to minimize damage.
Q7. What is the difference between OAuth2 and OIDC?
OAuth2 is an authorization framework that grants resource access permissions. OIDC adds an authentication layer on top of OAuth2, confirming user identity through ID Tokens. With OAuth2 alone you cannot know who the user is, but with OIDC you can.
Q8. Why should you not store JWT in localStorage?
localStorage is accessible via JavaScript, making tokens vulnerable to XSS attacks. Storing in httpOnly cookies prevents JavaScript access, protecting against XSS. The SameSite attribute also prevents CSRF.
Q9. What is the difference between HS256 and RS256?
HS256 is a symmetric algorithm using one secret key for both signing and verification. Suitable for single-server environments. RS256 is an asymmetric algorithm using a private key to sign and a public key to verify. In microservices, only the auth server holds the private key while other services verify with the public key.
Q10. What is the relationship between CORS and OAuth2?
When SPAs request the OAuth2 token endpoint, CORS configuration is required. The auth server must allow the SPA Origin. However, in Authorization Code Flow, token exchange happens on the backend, so there are no CORS issues. For SPAs using PKCE that request tokens from the front-end directly, CORS configuration is essential.
Q11. Can you send an ID Token to an API server?
No. ID Tokens are meant for client apps to verify user identity, while Access Tokens should be used for API access. The audience (aud) of an ID Token is the client app, not the API server. Mixing them creates security issues.
Q12. When do you use Client Credentials Flow?
For server-to-server communication (Machine-to-Machine) without user involvement. Examples: microservice API calls, batch jobs, cron jobs. Since there is no user context, there is no Refresh Token, and you simply request again on expiry.
Q13. What is the JWT Algorithm Confusion Attack?
In an RS256 environment, an attacker changes the JWT alg header to HS256 and uses the public key (which is publicly available) as the HS256 secret to create a valid signature. If the server trusts the alg claim, it accepts the forged token. Defense: Fix allowed algorithms on the server.
Q14. How do you implement token revocation?
JWT is Stateless so the server cannot directly revoke it. Methods: 1) Token blacklist (store revoked jti in Redis), 2) Short expiration + Refresh Token revocation, 3) Token version (per-user token_version field, reject on version mismatch). Method 2 is most practical.
Q15. What is DPoP (Demonstration of Proof-of-Possession)?
A mechanism where even if a token is stolen, the attacker cannot use it because the client must submit proof signed with its private key when using the token. The Access Token and DPoP Proof are submitted together, ensuring only the token owner can use it. Defined in RFC 9449.
12. Quiz
Q1. What are the four roles in OAuth2?
Resource Owner (the user), Client (the application), Authorization Server (issues tokens), and Resource Server (the API server).
Q2. What are the three components of JWT?
Header (algorithm and type info), Payload (claims including user info and expiration), and Signature (integrity verification). Each is Base64URL encoded and separated by dots (.).
Q3. Why are Refresh Tokens necessary?
To keep Access Token expiration times short while maintaining good user experience. When an Access Token expires, a Refresh Token obtains a new Access Token without requiring the user to log in again.
Q4. What is the difference between OIDC ID Token and Access Token?
ID Token is a JWT containing user authentication info, used by the client app for identity verification. Access Token represents API access authorization and is sent to the resource server. ID Tokens should never be sent to API servers.
Q5. What is the role of the state parameter?
It prevents CSRF (Cross-Site Request Forgery) attacks. A random value is included as state in the authorization request, and the callback verifies the same value is returned. This distinguishes forged authorization responses from legitimate ones.
References
- 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
Conclusion
Authentication and authorization are the foundation of all application security. OAuth2 and JWT are powerful, but if not implemented correctly, they become security vulnerabilities themselves.
Key takeaways:
- OAuth2 is an authorization framework -- use OIDC for authentication
- Authorization Code + PKCE is the standard for SPA/mobile
- JWT payload is encoded, not encrypted -- never store sensitive data
- Store tokens in httpOnly cookies -- localStorage is vulnerable to XSS
- Apply Refresh Token Rotation for theft detection
- Fix signing algorithms (RS256/ES256) to prevent algorithm confusion attacks
- Short Access Token lifespan + Refresh Tokens for balance
Build secure and user-friendly authentication systems based on this guide.