Split View: OAuth 2.0 & 인증 완전 가이드 2025: JWT, 세션, SSO, OIDC, Passkey까지
OAuth 2.0 & 인증 완전 가이드 2025: JWT, 세션, SSO, OIDC, Passkey까지
- 서론
- 1. Authentication vs Authorization
- 2. 세션 기반 인증 (Session-based)
- 3. 토큰 기반 인증 (Token-based)
- 4. JWT 심층 분석
- 5. OAuth 2.0 플로우
- 6. OpenID Connect (OIDC)
- 7. SSO (Single Sign-On)
- 8. 소셜 로그인 구현
- 9. Passkey / WebAuthn / FIDO2
- 10. 다중 인증 (MFA)
- 11. 보안 베스트 프랙티스
- 12. 인증 라이브러리 비교
- 13. 세션 vs JWT 비교표
- 14. 클이즈
- 참고 자료
서론
웹 애플리케이션에서 "인증(Authentication)"과 "인가(Authorization)"는 보안의 핵심입니다. 사용자 로그인부터 API 접근 제어, 마이크로서비스 간 통신까지, 올바른 인증 체계 없이는 안전한 시스템을 구축할 수 없습니다.
2025년 현재, 인증 생태계는 빠르게 진화하고 있습니다. 전통적인 세션 기반 인증에서 JWT 토큰 기반 인증, OAuth 2.0, OIDC를 거쳐 Passkey까지, 선택지가 그 어느 때보다 다양해졌습니다.
이 가이드에서는 Authentication vs Authorization의 근본적 차이부터 JWT 심층 분석, OAuth 2.0 모든 플로우, SSO, Passkey, 보안 베스트 프랙티스까지 웹 인증의 모든 것을 다룹니다.
1. Authentication vs Authorization
1.1 핵심 차이
Authentication (인증) - "당신은 누구인가?"
- 사용자의 신원을 확인하는 과정
- 로그인 과정 자체
- 예: 아이디/비밀번호, 생체 인식, OTP
Authorization (인가) - "당신은 무엇을 할 수 있는가?"
- 인증된 사용자의 권한을 확인하는 과정
- 리소스 접근 제어
- 예: 관리자만 삭제 가능, 본인 데이터만 조회 가능
1.2 인증/인가 흐름
1. 사용자가 로그인 요청 (Authentication)
POST /auth/login
Body: { "email": "user@example.com", "password": "..." }
2. 서버가 자격 증명 검증 (Authentication)
- DB에서 사용자 조회
- 비밀번호 해시 비교
- 인증 성공시 토큰/세션 발급
3. API 요청시 토큰/세션 첨부
GET /admin/users
Authorization: Bearer eyJhbGci...
4. 서버가 권한 확인 (Authorization)
- 토큰에서 역할(role) 추출
- 해당 엔드포인트 접근 권한 확인
- 권한 있음 -> 200 OK
- 권한 없음 -> 403 Forbidden
1.3 HTTP 상태 코드
401 Unauthorized = 인증(Authentication) 실패
- "당신이 누군지 모릅니다. 로그인하세요."
- 토큰 없음, 토큰 만료, 잘못된 자격 증명
403 Forbidden = 인가(Authorization) 실패
- "당신이 누군지는 알지만, 이 리소스에 접근 권한이 없습니다."
- 일반 사용자가 관리자 API 호출
2. 세션 기반 인증 (Session-based)
2.1 동작 원리
1. 클라이언트: POST /login (이메일, 비밀번호)
2. 서버: 자격 증명 검증
3. 서버: 세션 생성 (서버 메모리/Redis에 저장)
Session ID: "sess_abc123"
Data: { userId: 42, role: "admin", createdAt: "..." }
4. 서버: Set-Cookie 헤더로 세션 ID 반환
Set-Cookie: session_id=sess_abc123; HttpOnly; Secure; SameSite=Strict
5. 클라이언트: 이후 요청마다 쿠키 자동 전송
Cookie: session_id=sess_abc123
6. 서버: 세션 ID로 사용자 정보 조회
2.2 Express.js 세션 구현
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const app = express();
const redisClient = redis.createClient({ url: 'redis://localhost:6379' });
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS에서만 전송
httpOnly: true, // JavaScript 접근 차단 (XSS 방지)
sameSite: 'strict', // CSRF 방지
maxAge: 24 * 60 * 60 * 1000 // 24시간
}
}));
// 로그인
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: '잘못된 자격 증명' });
}
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: '로그인 성공' });
});
// 인증 미들웨어
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: '인증이 필요합니다' });
}
next();
}
// 인가 미들웨어
function requireRole(role) {
return (req, res, next) => {
if (req.session.role !== role) {
return res.status(403).json({ error: '권한이 없습니다' });
}
next();
};
}
// 로그아웃
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('connect.sid');
res.json({ message: '로그아웃 성공' });
});
});
2.3 세션 저장소 비교
| 저장소 | 장점 | 단점 | 적합한 경우 |
|---|---|---|---|
| 메모리 | 가장 빠름 | 서버 재시작시 소실, 공유 불가 | 개발 환경 |
| Redis | 빠른 속도, 공유 가능, TTL | 추가 인프라 필요 | 프로덕션 (권장) |
| DB (PostgreSQL) | 영속성, 감사 가능 | 느린 속도 | 감사가 필요한 경우 |
| File | 간단한 구현 | 느림, 공유 불가 | 소규모 앱 |
3. 토큰 기반 인증 (Token-based)
3.1 JWT 구조
JWT = Header.Payload.Signature (Base64URL 인코딩, 점으로 구분)
Header (헤더):
{
"alg": "RS256", // 서명 알고리즘
"typ": "JWT", // 토큰 타입
"kid": "key-id-1" // 키 식별자 (Key Rotation용)
}
Payload (페이로드 - Claims):
{
"iss": "https://auth.example.com", // Issuer (발급자)
"sub": "user-123", // Subject (주체)
"aud": "https://api.example.com", // Audience (대상)
"exp": 1711356600, // Expiration (만료)
"iat": 1711353000, // Issued At (발급)
"nbf": 1711353000, // Not Before (이전 사용 불가)
"jti": "unique-token-id", // JWT ID (고유 ID)
"email": "user@example.com", // 커스텀 클레임
"roles": ["admin", "user"] // 커스텀 클레임
}
Signature (서명):
RS256(
base64url(header) + "." + base64url(payload),
privateKey
)
3.2 서명 알고리즘 비교
| 알고리즘 | 타입 | 키 | 사용 사례 |
|---|---|---|---|
| HS256 | 대칭키 | 하나의 시크릿 키 | 단일 서버, 내부 서비스 |
| RS256 | 비대칭키 | 공개키/개인키 쌍 | 마이크로서비스, 외부 검증 |
| ES256 | 비대칭키 (ECDSA) | 짧은 키 길이 | 모바일, IoT (성능 중요) |
// HS256 - 같은 키로 서명하고 검증
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
// 서명
const token = jwt.sign({ sub: 'user-123' }, SECRET, { algorithm: 'HS256' });
// 검증 (같은 키 필요)
const decoded = jwt.verify(token, SECRET);
// RS256 - 개인키로 서명, 공개키로 검증
const fs = require('fs');
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// 서명 (인증 서버)
const token = jwt.sign(
{ sub: 'user-123', roles: ['admin'] },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// 검증 (API 서버 - 공개키만 필요)
const decoded = jwt.verify(token, publicKey);
3.3 JWT 검증 체크리스트
1. 서명 검증 (Signature Verification)
- 올바른 알고리즘으로 서명되었는지 확인
- alg: "none" 공격 방지 (반드시 알고리즘 명시)
2. 만료 시간 확인 (exp)
- 현재 시간이 exp보다 이전인지 확인
- 클럭 스큐를 고려한 약간의 여유 (leeway)
3. 발급자 확인 (iss)
- 신뢰할 수 있는 발급자인지 확인
4. 대상 확인 (aud)
- 이 토큰이 내 서비스를 위한 것인지 확인
5. Not Before 확인 (nbf)
- 토큰 사용 시작 시간 이후인지 확인
6. 추가 보안 검증
- 토큰이 블랙리스트에 있는지 확인
- 사용자가 비활성화되었는지 확인
4. JWT 심층 분석
4.1 Access Token과 Refresh Token
Access Token:
- 짧은 유효 기간 (15분 ~ 1시간)
- API 요청시 Authorization 헤더에 포함
- 탈취되어도 피해 기간이 제한적
- 서버에 저장하지 않음 (Stateless)
Refresh Token:
- 긴 유효 기간 (7일 ~ 30일)
- Access Token 갱신에만 사용
- 서버 DB/Redis에 저장 (Stateful)
- HttpOnly 쿠키에 저장 (XSS 방지)
4.2 토큰 갱신 흐름
1. 최초 로그인
POST /auth/login -> Access Token + Refresh Token 발급
2. API 요청
GET /api/data
Authorization: Bearer [access_token]
-> 200 OK (정상)
3. Access Token 만료
GET /api/data
Authorization: Bearer [expired_access_token]
-> 401 Unauthorized
4. 토큰 갱신
POST /auth/refresh
Cookie: refresh_token=...
-> 새 Access Token + 새 Refresh Token 발급
5. Refresh Token 만료
POST /auth/refresh
-> 401 Unauthorized -> 재로그인 필요
4.3 Refresh Token Rotation
// Refresh Token Rotation 구현
async function refreshTokens(refreshToken) {
// 1. DB에서 Refresh Token 조회
const storedToken = await db.findRefreshToken(refreshToken);
if (!storedToken) {
// 이미 사용된 Refresh Token -> 모든 토큰 무효화 (탈취 감지)
await db.revokeAllTokensForUser(storedToken.userId);
throw new Error('Refresh Token reuse detected');
}
// 2. 유효성 확인
if (storedToken.expiresAt < Date.now()) {
throw new Error('Refresh Token expired');
}
// 3. 이전 Refresh Token 무효화
await db.revokeRefreshToken(refreshToken);
// 4. 새 토큰 쌍 발급
const newAccessToken = jwt.sign(
{ sub: storedToken.userId, roles: storedToken.roles },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
const newRefreshToken = crypto.randomUUID();
await db.saveRefreshToken({
token: newRefreshToken,
userId: storedToken.userId,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000
});
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
4.4 토큰 블랙리스트
// Redis 기반 블랙리스트
const redis = require('redis');
const client = redis.createClient();
// 토큰 블랙리스트에 추가 (로그아웃시)
async function blacklistToken(token) {
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await client.set(`blacklist:${decoded.jti}`, '1', { EX: ttl });
}
}
// 토큰 검증시 블랙리스트 확인
async function verifyToken(token) {
const decoded = jwt.verify(token, publicKey);
const isBlacklisted = await client.get(`blacklist:${decoded.jti}`);
if (isBlacklisted) {
throw new Error('Token has been revoked');
}
return decoded;
}
4.5 JWT 베스트 프랙티스
DO (해야 할 것):
- Access Token 유효 기간을 짧게 (15분)
- RS256 또는 ES256 사용 (비대칭 키)
- jti 클레임으로 토큰 고유 식별
- Refresh Token은 서버 측에서 관리
- 민감한 정보는 Payload에 넣지 않기
DON'T (하지 말 것):
- JWT를 localStorage에 저장 (XSS에 취약)
- HS256으로 마이크로서비스 간 사용
- 토큰에 비밀번호/카드번호 포함
- alg: "none"을 허용
- 유효 기간 없이 토큰 발급
5. OAuth 2.0 플로우
5.1 OAuth 2.0 역할
Resource Owner (리소스 소유자):
- 최종 사용자 (예: Google 계정 소유자)
Client (클라이언트):
- 리소스 접근을 요청하는 애플리케이션 (예: 내 웹앱)
Authorization Server (인가 서버):
- 토큰을 발급하는 서버 (예: Google OAuth 서버)
Resource Server (리소스 서버):
- 보호된 리소스를 호스팅하는 서버 (예: Google API)
5.2 Authorization Code Flow (+ PKCE)
가장 권장되는 플로우. 웹앱, SPA, 모바일 앱 모두에서 사용.
PKCE (Proof Key for Code Exchange)는 SPA/모바일에서 필수.
1. 클라이언트가 PKCE 파라미터 생성
code_verifier = random(43-128 chars)
code_challenge = BASE64URL(SHA256(code_verifier))
2. 인가 요청
GET /authorize?
response_type=code&
client_id=my-app&
redirect_uri=https://myapp.com/callback&
scope=openid profile email&
state=random-csrf-token&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
3. 사용자가 로그인 + 동의
4. 인가 서버가 Authorization Code 반환
302 Redirect: https://myapp.com/callback?code=AUTH_CODE&state=random-csrf-token
5. Authorization Code를 Access Token으로 교환
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://myapp.com/callback&
client_id=my-app&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
6. 토큰 응답
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIH...",
"id_token": "eyJhbGci...",
"scope": "openid profile email"
}
5.3 Client Credentials Flow
서버 간 통신 (Machine-to-Machine). 사용자 개입 없음.
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=service-a&
client_secret=secret-value&
scope=read:data write:data
응답:
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:data write:data"
}
사용 사례:
- 마이크로서비스 간 API 호출
- 배치 처리 시스템
- CI/CD 파이프라인에서의 API 접근
5.4 Device Code Flow
입력이 제한된 디바이스용 (스마트 TV, CLI 도구, IoT)
1. 디바이스가 코드 요청
POST /device/code
client_id=tv-app
응답:
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.example.com/device",
"interval": 5,
"expires_in": 1800
}
2. 사용자에게 표시
"https://auth.example.com/device 에서 코드 WDJB-MJHT를 입력하세요"
3. 사용자가 다른 디바이스(폰/PC)에서 코드 입력 + 로그인
4. 디바이스가 폴링
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code&
device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS&
client_id=tv-app
(사용자가 아직 인증 안함 -> "authorization_pending")
(사용자가 인증 완료 -> Access Token 반환)
5.5 Implicit Flow (비권장)
더 이상 권장되지 않음! PKCE가 포함된 Authorization Code Flow를 사용하세요.
비권장 이유:
1. Access Token이 URL Fragment에 노출 (#access_token=...)
2. 브라우저 히스토리에 토큰 기록
3. Refresh Token을 발급할 수 없음
4. Token 교체 공격(Token Substitution Attack)에 취약
대안: Authorization Code + PKCE
- SPA에서도 안전하게 사용 가능
- Refresh Token 지원
- code_verifier로 코드 가로채기 방지
6. OpenID Connect (OIDC)
6.1 OIDC란
OAuth 2.0 위에 구축된 인증 레이어.
OAuth 2.0 = 인가(Authorization)만 담당
OIDC = 인증(Authentication)을 추가
OAuth 2.0: "이 앱이 당신의 Google Drive에 접근해도 됩니까?"
OIDC: "이 사용자가 실제로 alice@gmail.com인지 확인"
핵심 추가 사항:
1. ID Token (JWT 형식의 사용자 정보)
2. UserInfo 엔드포인트
3. 표준화된 클레임 (name, email, picture 등)
4. Discovery 문서 (/.well-known/openid-configuration)
6.2 ID Token
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "my-app-client-id",
"exp": 1711356600,
"iat": 1711353000,
"nonce": "n-0S6_WzA2Mj",
"email": "alice@gmail.com",
"email_verified": true,
"name": "Alice Kim",
"picture": "https://lh3.googleusercontent.com/a/..."
}
6.3 Discovery 문서
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",
"scopes_supported": ["openid", "email", "profile"],
"response_types_supported": ["code", "token", "id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
}
7. SSO (Single Sign-On)
7.1 SSO 개념
한 번의 로그인으로 여러 서비스에 접근하는 메커니즘.
예시:
Google 계정으로 로그인하면 Gmail, Drive, YouTube, Calendar 모두 접근 가능.
장점:
- 사용자 경험 향상 (로그인 한 번)
- 비밀번호 피로도 감소
- 중앙화된 사용자 관리
- 보안 정책 일관성
단점:
- 단일 장애점 (IdP 장애시 모든 서비스 영향)
- 구현 복잡성
- IdP에 대한 의존성
7.2 SAML 2.0 vs OIDC
| 항목 | SAML 2.0 | OIDC |
|---|---|---|
| 데이터 형식 | XML | JSON/JWT |
| 전송 | HTTP Redirect/POST | HTTP Redirect |
| 토큰 | Assertion (XML) | ID Token (JWT) |
| 주 사용처 | 엔터프라이즈 SSO | 웹/모바일 앱 |
| 복잡도 | 높음 | 낮음 |
| 모바일 지원 | 제한적 | 우수 |
| 표준화 | 2005년 | 2014년 |
SAML 2.0 흐름:
1. 사용자가 SP(Service Provider) 접근
2. SP가 SAML Request 생성 -> IdP로 리다이렉트
3. 사용자가 IdP에서 인증
4. IdP가 SAML Assertion(XML) 생성 -> SP로 POST
5. SP가 Assertion 검증 -> 세션 생성
OIDC 흐름:
1. 사용자가 앱 접근
2. 앱이 IdP로 Authorization 요청 리다이렉트
3. 사용자가 IdP에서 인증
4. IdP가 Authorization Code 반환
5. 앱이 Code를 ID Token + Access Token으로 교환
8. 소셜 로그인 구현
8.1 Google 로그인 (Next.js + NextAuth.js)
// app/api/auth/[...nextauth]/route.js
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';
const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token;
token.provider = account.provider;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken;
session.provider = token.provider;
return session;
},
},
pages: {
signIn: '/auth/signin',
error: '/auth/error',
},
});
export { handler as GET, handler as POST };
8.2 GitHub 로그인 (Express.js + Passport.js)
const passport = require('passport');
const GitHubStrategy = require('passport-github2').Strategy;
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: 'https://myapp.com/auth/github/callback'
},
async (accessToken, refreshToken, profile, done) => {
let user = await db.findUserByGitHubId(profile.id);
if (!user) {
user = await db.createUser({
githubId: profile.id,
name: profile.displayName,
email: profile.emails[0].value,
avatar: profile.photos[0].value,
});
}
return done(null, user);
}
));
// 라우트
app.get('/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
8.3 Apple 로그인 특이사항
Apple 로그인은 다른 소셜 로그인과 차이가 있음:
1. 이메일 숨기기 (Private Email Relay)
- 사용자가 실제 이메일 대신 고유 릴레이 주소 제공 가능
- 예: abc123@privaterelay.appleid.com
2. 사용자 정보 최초 1회만 제공
- 이름, 이메일은 첫 로그인시에만 반환
- 반드시 첫 응답에서 저장해야 함!
3. client_secret이 JWT
- 다른 프로바이더와 달리 client_secret을 직접 JWT로 생성
- Apple 개발자 포털의 키로 서명
4. 웹에서 Form POST 방식
- callback이 POST 방식으로 전달
9. Passkey / WebAuthn / FIDO2
9.1 Passkey란
비밀번호 없는 인증의 미래.
생체 인식(지문/얼굴), 디바이스 PIN, 보안 키로 로그인.
Passkey = WebAuthn + FIDO2 + 클라우드 동기화
장점:
- 피싱 불가능 (도메인에 바인딩)
- 비밀번호 유출 없음
- 사용자 경험 향상 (터치/얼굴만으로 로그인)
- 크로스 디바이스 지원 (iCloud Keychain, Google Password Manager)
브라우저 지원:
- Chrome 108+ (2022.12)
- Safari 16+ (2022.09)
- Firefox 122+ (2024.01)
- Edge 108+ (2022.12)
9.2 등록 (Registration) 흐름
// 서버: Registration Options 생성
app.post('/webauthn/register/options', async (req, res) => {
const user = req.user;
const options = {
challenge: crypto.randomBytes(32),
rp: {
name: 'My App',
id: 'myapp.com'
},
user: {
id: Buffer.from(user.id),
name: user.email,
displayName: user.name
},
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256
{ alg: -257, type: 'public-key' } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'preferred',
residentKey: 'required'
},
timeout: 60000
};
req.session.challenge = options.challenge;
res.json(options);
});
// 클라이언트: Passkey 생성
const credential = await navigator.credentials.create({
publicKey: registrationOptions
});
// 서버: 등록 완료
app.post('/webauthn/register/verify', async (req, res) => {
const { credential } = req.body;
// challenge 검증, origin 검증, 공개키 추출 및 저장
const verified = await verifyRegistration(credential, req.session.challenge);
if (verified) {
await db.saveCredential({
credentialId: credential.id,
publicKey: verified.publicKey,
userId: req.user.id,
counter: verified.counter
});
res.json({ success: true });
}
});
9.3 인증 (Authentication) 흐름
// 서버: Authentication Options 생성
app.post('/webauthn/login/options', async (req, res) => {
const options = {
challenge: crypto.randomBytes(32),
rpId: 'myapp.com',
userVerification: 'preferred',
timeout: 60000
};
req.session.challenge = options.challenge;
res.json(options);
});
// 클라이언트: Passkey로 인증
const assertion = await navigator.credentials.get({
publicKey: authenticationOptions
});
// 서버: 인증 검증
app.post('/webauthn/login/verify', async (req, res) => {
const { assertion } = req.body;
const credential = await db.findCredential(assertion.id);
const verified = await verifyAuthentication(
assertion,
credential.publicKey,
req.session.challenge,
credential.counter
);
if (verified) {
// 카운터 업데이트 (리플레이 공격 방지)
await db.updateCounter(assertion.id, verified.newCounter);
// 세션/토큰 발급
const token = jwt.sign({ sub: credential.userId }, privateKey);
res.json({ token });
}
});
10. 다중 인증 (MFA)
10.1 MFA 방식 비교
| 방식 | 보안 수준 | UX | 구현 난이도 |
|---|---|---|---|
| SMS OTP | 낮음 (SIM 스와핑 취약) | 보통 | 낮음 |
| TOTP (Google Authenticator) | 중간 | 보통 | 중간 |
| Push 알림 (Duo, Okta) | 높음 | 좋음 | 높음 |
| 하드웨어 키 (YubiKey) | 매우 높음 | 보통 | 중간 |
| Passkey | 매우 높음 | 매우 좋음 | 중간 |
10.2 TOTP 구현
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// MFA 설정 - 시크릿 생성
app.post('/mfa/setup', async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`,
issuer: 'MyApp'
});
// QR 코드 생성
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
// 시크릿을 임시 저장 (검증 전)
await db.saveTempMfaSecret(req.user.id, secret.base32);
res.json({
qrCode: qrCodeUrl,
manualEntry: secret.base32
});
});
// MFA 설정 - 코드 검증 후 활성화
app.post('/mfa/verify-setup', async (req, res) => {
const { code } = req.body;
const secret = await db.getTempMfaSecret(req.user.id);
const verified = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: code,
window: 1 // 30초 전후 허용
});
if (verified) {
await db.enableMfa(req.user.id, secret);
// 백업 코드 생성
const backupCodes = Array.from({ length: 10 }, () =>
crypto.randomBytes(4).toString('hex')
);
await db.saveBackupCodes(req.user.id, backupCodes);
res.json({ success: true, backupCodes });
} else {
res.status(400).json({ error: '잘못된 코드' });
}
});
// 로그인시 MFA 검증
app.post('/auth/mfa-verify', async (req, res) => {
const { code, userId } = req.body;
const user = await db.getUser(userId);
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: code,
window: 1
});
if (verified) {
const token = jwt.sign({ sub: user.id, mfa: true }, privateKey);
res.json({ token });
} else {
res.status(401).json({ error: '잘못된 MFA 코드' });
}
});
11. 보안 베스트 프랙티스
11.1 CSRF 방지
CSRF (Cross-Site Request Forgery):
악성 사이트가 인증된 사용자의 권한으로 요청을 위조.
방어 방법:
1. SameSite 쿠키 속성
Set-Cookie: session=abc; SameSite=Strict
2. CSRF 토큰
- 서버가 폼과 함께 고유 토큰 발급
- 폼 제출시 토큰 포함
- 서버가 토큰 검증
3. Double Submit Cookie
- CSRF 토큰을 쿠키와 요청 본문 모두에 포함
- 두 값이 일치하는지 확인
4. Origin/Referer 검증
- 요청의 Origin 헤더가 허용된 도메인인지 확인
11.2 XSS 방지와 토큰 저장
토큰 저장 위치별 보안 비교:
localStorage:
- XSS에 취약 (JavaScript로 접근 가능)
- CSRF에는 안전
- 사용하지 않는 것을 강력히 권장
sessionStorage:
- XSS에 취약
- 탭 간 공유 안됨
- localStorage보다 약간 나음
HttpOnly Cookie:
- XSS에 안전 (JavaScript 접근 불가)
- CSRF 보호 필요 (SameSite=Strict)
- 가장 권장되는 방법
메모리 (변수):
- XSS/CSRF 모두에 가장 안전
- 페이지 새로고침시 소실
- SPA에서 사용 가능, Refresh Token은 HttpOnly 쿠키
11.3 CORS 설정
// Express.js CORS 설정
const cors = require('cors');
// Bad: 모든 도메인 허용
app.use(cors());
// Good: 특정 도메인만 허용
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // 쿠키 포함 허용
maxAge: 86400 // Preflight 캐시 24시간
}));
11.4 보안 헤더
# 필수 보안 헤더
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Content-Security-Policy: default-src 'self'; script-src 'self'
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
11.5 비밀번호 정책
// bcrypt으로 비밀번호 해싱
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
// 비밀번호 해싱
async function hashPassword(plainPassword) {
return await bcrypt.hash(plainPassword, SALT_ROUNDS);
}
// 비밀번호 검증
async function verifyPassword(plainPassword, hashedPassword) {
return await bcrypt.compare(plainPassword, hashedPassword);
}
// 비밀번호 강도 검증
function validatePasswordStrength(password) {
const rules = [
{ test: /.{8,}/, message: '최소 8자 이상' },
{ test: /[A-Z]/, message: '대문자 1개 이상' },
{ test: /[a-z]/, message: '소문자 1개 이상' },
{ test: /[0-9]/, message: '숫자 1개 이상' },
{ test: /[^A-Za-z0-9]/, message: '특수문자 1개 이상' },
];
const failures = rules.filter(r => !r.test.test(password));
return { valid: failures.length === 0, failures };
}
12. 인증 라이브러리 비교
12.1 주요 라이브러리
| 라이브러리 | 타입 | 언어 | 주 사용 사례 |
|---|---|---|---|
| NextAuth.js (Auth.js) | 라이브러리 | JS/TS | Next.js 소셜 로그인 |
| Passport.js | 미들웨어 | JS | Express 다양한 전략 |
| Lucia | 라이브러리 | JS/TS | 세션 기반, 직접 제어 |
| Clerk | BaaS | JS/TS | 풀스택 인증 UI |
| Auth0 | IDaaS | 다양 | 엔터프라이즈 SSO |
| Keycloak | 자체 호스팅 | Java | 엔터프라이즈 IdP |
| Supabase Auth | BaaS | JS/TS | Supabase 생태계 |
| Firebase Auth | BaaS | 다양 | Google 생태계 |
12.2 선택 기준
소규모 프로젝트 + 빠른 구현:
-> NextAuth.js / Clerk / Supabase Auth
직접 제어 + 커스텀 요구:
-> Lucia / Passport.js
엔터프라이즈 SSO + SAML:
-> Auth0 / Keycloak
마이크로서비스 + 자체 호스팅:
-> Keycloak
서버리스 + Google 생태계:
-> Firebase Auth
13. 세션 vs JWT 비교표
| 항목 | 세션 기반 | JWT 기반 |
|---|---|---|
| 상태 관리 | Stateful (서버 저장) | Stateless (토큰 자체 포함) |
| 저장 위치 | 서버 (Redis/DB) | 클라이언트 (쿠키/메모리) |
| 확장성 | 세션 공유 필요 | 서버 간 공유 불필요 |
| 보안 (탈취시) | 서버에서 즉시 무효화 | 만료까지 유효 |
| 크기 | 쿠키: 작음 (세션 ID) | 토큰: 큼 (Claims 포함) |
| 마이크로서비스 | 중앙 세션 저장소 필요 | 각 서비스에서 독립 검증 |
| 모바일 지원 | 쿠키 관리 필요 | Authorization 헤더 사용 |
| 로그아웃 | 세션 삭제로 즉시 | 블랙리스트 필요 |
| 서버 부하 | 매 요청 DB/Redis 조회 | 서명 검증만 (CPU) |
| 크로스 도메인 | 쿠키 도메인 제한 | 헤더로 자유롭게 전달 |
| 구현 복잡도 | 낮음 | 중간 (Refresh Token 등) |
| CSRF | 취약 (쿠키 자동 전송) | 안전 (Authorization 헤더) |
| XSS | 안전 (HttpOnly 쿠키) | 취약 (localStorage 저장시) |
| 오프라인 사용 | 불가 | 가능 (토큰 내 정보 활용) |
| 디버깅 | 서버 로그 확인 | jwt.io에서 디코딩 가능 |
14. 클이즈
Q1. Authentication vs Authorization
401 Unauthorized와 403 Forbidden의 차이를 구체적인 시나리오로 설명하세요.
정답:
-
401 Unauthorized (인증 실패): 사용자가 자신을 증명하지 않은 상태. 예: Authorization 헤더 없이 API 호출, 만료된 JWT 토큰 사용, 잘못된 비밀번호 입력. "당신이 누군지 모릅니다."
-
403 Forbidden (인가 실패): 사용자는 인증되었지만 해당 리소스에 대한 권한이 없는 상태. 예: 일반 사용자가 DELETE /admin/users API를 호출, 다른 사용자의 비공개 데이터 접근 시도. "당신이 누군지는 알지만 이 작업을 할 권한이 없습니다."
Q2. JWT 보안
JWT를 localStorage에 저장하면 안 되는 이유와 권장 저장 방법을 설명하세요.
정답:
localStorage에 저장하면 XSS(Cross-Site Scripting) 공격에 취약합니다. 악성 스크립트가 주입되면 localStorage.getItem('token')으로 토큰을 탈취할 수 있습니다.
권장 저장 방법:
- Access Token: 메모리(JavaScript 변수)에 저장. 페이지 새로고침시 소실되지만 가장 안전.
- Refresh Token: HttpOnly + Secure + SameSite=Strict 쿠키에 저장. JavaScript에서 접근 불가하여 XSS에 안전.
- Access Token이 만료되면 Refresh Token 쿠키로 갱신.
Q3. OAuth 2.0 PKCE
SPA에서 Authorization Code Flow에 PKCE가 필수인 이유를 설명하세요.
정답: SPA는 소스코드가 브라우저에 완전히 노출되므로 client_secret을 안전하게 보관할 수 없습니다. PKCE 없이는 Authorization Code가 가로채지면 공격자가 이를 Access Token으로 교환할 수 있습니다.
PKCE는 이 문제를 해결합니다:
- 클라이언트가
code_verifier(랜덤 문자열)를 생성 code_challenge = SHA256(code_verifier)를 인가 요청에 포함- 토큰 교환시 원본
code_verifier를 전송 - 서버가
SHA256(code_verifier) == code_challenge를 검증
공격자가 Authorization Code를 가로채도 code_verifier를 모르면 토큰 교환이 불가능합니다.
Q4. Passkey
Passkey가 비밀번호보다 안전한 이유를 3가지 설명하세요.
정답:
-
피싱 불가능: Passkey는 등록된 도메인(RP ID)에 바인딩됩니다. 가짜 사이트에서는 Passkey가 작동하지 않으므로 피싱 공격이 원천적으로 차단됩니다.
-
서버 유출 안전: 서버에는 공개키만 저장됩니다. DB가 유출되어도 공개키로는 인증을 위조할 수 없습니다. 비밀번호는 해시되어 저장되지만 취약한 비밀번호는 크랙될 수 있습니다.
-
재사용 불가: 각 서비스마다 고유한 키 쌍이 생성됩니다. 하나의 서비스에서 유출되어도 다른 서비스에 영향이 없습니다. 반면 비밀번호는 사용자들이 여러 사이트에서 재사용하는 경우가 많습니다.
Q5. 세션 vs JWT
마이크로서비스 환경에서 세션 기반 인증보다 JWT가 유리한 이유를 설명하세요.
정답: 마이크로서비스 환경에서 JWT가 유리한 이유:
-
독립적 검증: 각 서비스가 공개키만 있으면 독립적으로 토큰을 검증할 수 있습니다. 세션은 중앙 세션 저장소(Redis)를 모든 서비스가 공유해야 합니다.
-
네트워크 부하 감소: JWT는 서명 검증만으로 인증이 완료되어 매 요청마다 DB/Redis를 조회할 필요가 없습니다.
-
서비스 간 전파 용이: JWT를 Authorization 헤더에 포함하여 서비스 간 전달만 하면 됩니다. 사용자 정보가 토큰 내에 포함되어 있어 추가 조회가 불필요합니다.
-
스케일링 용이: 새 서비스 인스턴스 추가시 세션 저장소 연결 설정이 불필요하며, 공개키 배포만 하면 됩니다.
참고 자료
- OAuth 2.0 RFC 6749 - https://tools.ietf.org/html/rfc6749
- JWT RFC 7519 - https://tools.ietf.org/html/rfc7519
- PKCE RFC 7636 - https://tools.ietf.org/html/rfc7636
- OpenID Connect Core - https://openid.net/specs/openid-connect-core-1_0.html
- WebAuthn Specification - https://www.w3.org/TR/webauthn-2/
- FIDO2 Specifications - https://fidoalliance.org/fido2/
- OWASP Authentication Cheat Sheet - https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Session Management - https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- Auth0 Documentation - https://auth0.com/docs
- NextAuth.js (Auth.js) Docs - https://authjs.dev/
- Passkeys.dev - https://passkeys.dev/
- SAML 2.0 Technical Overview - https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html
- OAuth 2.0 Security Best Current Practice - https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
- Keycloak Documentation - https://www.keycloak.org/documentation
OAuth 2.0 & Authentication Complete Guide 2025: JWT, Sessions, SSO, OIDC, Passkey
- Introduction
- 1. Authentication vs Authorization
- 2. Session-based Authentication
- 3. Token-based Authentication
- 4. JWT Deep Dive
- 5. OAuth 2.0 Flows
- 6. OpenID Connect (OIDC)
- 7. SSO (Single Sign-On)
- 8. Social Login Implementation
- 9. Passkey / WebAuthn / FIDO2
- 10. Multi-Factor Authentication (MFA)
- 11. Security Best Practices
- 12. Auth Libraries Comparison
- 13. Session vs JWT Comparison Table
- 14. Quiz
- References
Introduction
In web applications, "Authentication" and "Authorization" are the cornerstones of security. From user login to API access control and inter-microservice communication, you cannot build a secure system without a proper authentication scheme.
As of 2025, the authentication ecosystem is evolving rapidly. From traditional session-based authentication to JWT token-based authentication, OAuth 2.0, OIDC, and Passkey, the choices are more diverse than ever.
This guide covers everything about web authentication, from the fundamental difference between Authentication vs Authorization, through JWT deep dive, all OAuth 2.0 flows, SSO, Passkey, to security best practices.
1. Authentication vs Authorization
1.1 Core Difference
Authentication - "Who are you?"
- The process of verifying a user's identity
- The login process itself
- Examples: username/password, biometrics, OTP
Authorization - "What are you allowed to do?"
- The process of checking an authenticated user's permissions
- Resource access control
- Examples: only admins can delete, users can only view their own data
1.2 Authentication/Authorization Flow
1. User sends login request (Authentication)
POST /auth/login
Body: { "email": "user@example.com", "password": "..." }
2. Server verifies credentials (Authentication)
- Look up user in DB
- Compare password hash
- Issue token/session on success
3. Include token/session with API requests
GET /admin/users
Authorization: Bearer eyJhbGci...
4. Server checks permissions (Authorization)
- Extract role from token
- Verify access to endpoint
- Has permission -> 200 OK
- No permission -> 403 Forbidden
1.3 HTTP Status Codes
401 Unauthorized = Authentication failure
- "I don't know who you are. Please log in."
- Missing token, expired token, invalid credentials
403 Forbidden = Authorization failure
- "I know who you are, but you don't have access to this resource."
- Regular user calling admin-only API
2. Session-based Authentication
2.1 How It Works
1. Client: POST /login (email, password)
2. Server: Validates credentials
3. Server: Creates session (stored in server memory/Redis)
Session ID: "sess_abc123"
Data: { userId: 42, role: "admin", createdAt: "..." }
4. Server: Returns session ID via Set-Cookie header
Set-Cookie: session_id=sess_abc123; HttpOnly; Secure; SameSite=Strict
5. Client: Automatically sends cookie with every subsequent request
Cookie: session_id=sess_abc123
6. Server: Looks up user info using session ID
2.2 Express.js Session Implementation
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const app = express();
const redisClient = redis.createClient({ url: 'redis://localhost:6379' });
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Only sent over HTTPS
httpOnly: true, // Block JavaScript access (XSS prevention)
sameSite: 'strict', // CSRF prevention
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Login successful' });
});
// Authentication middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
// Authorization middleware
function requireRole(role) {
return (req, res, next) => {
if (req.session.role !== role) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Logout
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('connect.sid');
res.json({ message: 'Logout successful' });
});
});
2.3 Session Store Comparison
| Store | Pros | Cons | Best For |
|---|---|---|---|
| Memory | Fastest | Lost on restart, not shared | Development |
| Redis | Fast, shared, TTL support | Extra infrastructure | Production (recommended) |
| DB (PostgreSQL) | Persistent, auditable | Slow | Audit requirements |
| File | Simple implementation | Slow, not shared | Small apps |
3. Token-based Authentication
3.1 JWT Structure
JWT = Header.Payload.Signature (Base64URL encoded, separated by dots)
Header:
{
"alg": "RS256", // Signing algorithm
"typ": "JWT", // Token type
"kid": "key-id-1" // Key identifier (for Key Rotation)
}
Payload (Claims):
{
"iss": "https://auth.example.com", // Issuer
"sub": "user-123", // Subject
"aud": "https://api.example.com", // Audience
"exp": 1711356600, // Expiration
"iat": 1711353000, // Issued At
"nbf": 1711353000, // Not Before
"jti": "unique-token-id", // JWT ID
"email": "user@example.com", // Custom claim
"roles": ["admin", "user"] // Custom claim
}
Signature:
RS256(
base64url(header) + "." + base64url(payload),
privateKey
)
3.2 Signing Algorithm Comparison
| Algorithm | Type | Key | Use Cases |
|---|---|---|---|
| HS256 | Symmetric | Single secret key | Single server, internal services |
| RS256 | Asymmetric | Public/private key pair | Microservices, external verification |
| ES256 | Asymmetric (ECDSA) | Shorter key length | Mobile, IoT (performance critical) |
// HS256 - Sign and verify with the same key
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
// Sign
const token = jwt.sign({ sub: 'user-123' }, SECRET, { algorithm: 'HS256' });
// Verify (same key required)
const decoded = jwt.verify(token, SECRET);
// RS256 - Sign with private key, verify with public key
const fs = require('fs');
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// Sign (Auth server)
const token = jwt.sign(
{ sub: 'user-123', roles: ['admin'] },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// Verify (API server - only needs public key)
const decoded = jwt.verify(token, publicKey);
3.3 JWT Verification Checklist
1. Signature Verification
- Verify signed with the correct algorithm
- Prevent alg: "none" attacks (always specify algorithm)
2. Expiration Check (exp)
- Ensure current time is before exp
- Allow slight leeway for clock skew
3. Issuer Check (iss)
- Verify from a trusted issuer
4. Audience Check (aud)
- Verify this token is meant for my service
5. Not Before Check (nbf)
- Ensure current time is after nbf
6. Additional Security Checks
- Check if token is on blacklist
- Check if user is deactivated
4. JWT Deep Dive
4.1 Access Token and Refresh Token
Access Token:
- Short validity (15 minutes - 1 hour)
- Included in Authorization header for API requests
- Limited damage window if stolen
- Not stored on server (Stateless)
Refresh Token:
- Long validity (7 days - 30 days)
- Used only to renew Access Tokens
- Stored in server DB/Redis (Stateful)
- Stored in HttpOnly cookie (XSS prevention)
4.2 Token Renewal Flow
1. Initial Login
POST /auth/login -> Access Token + Refresh Token issued
2. API Request
GET /api/data
Authorization: Bearer [access_token]
-> 200 OK (success)
3. Access Token Expired
GET /api/data
Authorization: Bearer [expired_access_token]
-> 401 Unauthorized
4. Token Renewal
POST /auth/refresh
Cookie: refresh_token=...
-> New Access Token + New Refresh Token issued
5. Refresh Token Expired
POST /auth/refresh
-> 401 Unauthorized -> Re-login required
4.3 Refresh Token Rotation
// Refresh Token Rotation implementation
async function refreshTokens(refreshToken) {
// 1. Look up Refresh Token in DB
const storedToken = await db.findRefreshToken(refreshToken);
if (!storedToken) {
// Already used Refresh Token -> invalidate all tokens (theft detected)
await db.revokeAllTokensForUser(storedToken.userId);
throw new Error('Refresh Token reuse detected');
}
// 2. Validate
if (storedToken.expiresAt < Date.now()) {
throw new Error('Refresh Token expired');
}
// 3. Invalidate previous Refresh Token
await db.revokeRefreshToken(refreshToken);
// 4. Issue new token pair
const newAccessToken = jwt.sign(
{ sub: storedToken.userId, roles: storedToken.roles },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
const newRefreshToken = crypto.randomUUID();
await db.saveRefreshToken({
token: newRefreshToken,
userId: storedToken.userId,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000
});
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
4.4 Token Blacklist
// Redis-based blacklist
const redis = require('redis');
const client = redis.createClient();
// Add token to blacklist (on logout)
async function blacklistToken(token) {
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await client.set(`blacklist:${decoded.jti}`, '1', { EX: ttl });
}
}
// Check blacklist during verification
async function verifyToken(token) {
const decoded = jwt.verify(token, publicKey);
const isBlacklisted = await client.get(`blacklist:${decoded.jti}`);
if (isBlacklisted) {
throw new Error('Token has been revoked');
}
return decoded;
}
4.5 JWT Best Practices
DO:
- Keep Access Token validity short (15 minutes)
- Use RS256 or ES256 (asymmetric keys)
- Use jti claim for unique token identification
- Manage Refresh Tokens server-side
- Never put sensitive data in Payload
DON'T:
- Store JWT in localStorage (vulnerable to XSS)
- Use HS256 across microservices
- Include passwords/card numbers in tokens
- Allow alg: "none"
- Issue tokens without expiration
5. OAuth 2.0 Flows
5.1 OAuth 2.0 Roles
Resource Owner:
- The end user (e.g., Google account holder)
Client:
- The application requesting resource access (e.g., your web app)
Authorization Server:
- The server issuing tokens (e.g., Google OAuth server)
Resource Server:
- The server hosting protected resources (e.g., Google API)
5.2 Authorization Code Flow (+ PKCE)
The most recommended flow. Used in web apps, SPAs, and mobile apps.
PKCE (Proof Key for Code Exchange) is mandatory for SPAs/mobile.
1. Client generates PKCE parameters
code_verifier = random(43-128 chars)
code_challenge = BASE64URL(SHA256(code_verifier))
2. Authorization request
GET /authorize?
response_type=code&
client_id=my-app&
redirect_uri=https://myapp.com/callback&
scope=openid profile email&
state=random-csrf-token&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
3. User logs in + consents
4. Authorization Server returns Authorization Code
302 Redirect: https://myapp.com/callback?code=AUTH_CODE&state=random-csrf-token
5. Exchange Authorization Code for Access Token
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://myapp.com/callback&
client_id=my-app&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
6. Token response
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIH...",
"id_token": "eyJhbGci...",
"scope": "openid profile email"
}
5.3 Client Credentials Flow
Server-to-server communication (Machine-to-Machine). No user involvement.
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=service-a&
client_secret=secret-value&
scope=read:data write:data
Response:
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:data write:data"
}
Use cases:
- Microservice-to-microservice API calls
- Batch processing systems
- API access in CI/CD pipelines
5.4 Device Code Flow
For input-constrained devices (Smart TVs, CLI tools, IoT)
1. Device requests a code
POST /device/code
client_id=tv-app
Response:
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.example.com/device",
"interval": 5,
"expires_in": 1800
}
2. Display to user
"Go to https://auth.example.com/device and enter code WDJB-MJHT"
3. User enters code on another device (phone/PC) + logs in
4. Device polls
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code&
device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS&
client_id=tv-app
(User not yet authenticated -> "authorization_pending")
(User authenticated -> Access Token returned)
5.5 Implicit Flow (Deprecated)
No longer recommended! Use Authorization Code Flow with PKCE instead.
Why deprecated:
1. Access Token exposed in URL Fragment (#access_token=...)
2. Token recorded in browser history
3. Cannot issue Refresh Tokens
4. Vulnerable to Token Substitution Attacks
Alternative: Authorization Code + PKCE
- Safe to use even in SPAs
- Supports Refresh Tokens
- code_verifier prevents code interception
6. OpenID Connect (OIDC)
6.1 What is OIDC
An authentication layer built on top of OAuth 2.0.
OAuth 2.0 = Handles Authorization only
OIDC = Adds Authentication
OAuth 2.0: "Can this app access your Google Drive?"
OIDC: "Verify that this user is actually alice@gmail.com"
Key additions:
1. ID Token (user information in JWT format)
2. UserInfo endpoint
3. Standardized claims (name, email, picture, etc.)
4. Discovery document (/.well-known/openid-configuration)
6.2 ID Token
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "my-app-client-id",
"exp": 1711356600,
"iat": 1711353000,
"nonce": "n-0S6_WzA2Mj",
"email": "alice@gmail.com",
"email_verified": true,
"name": "Alice Kim",
"picture": "https://lh3.googleusercontent.com/a/..."
}
6.3 Discovery Document
GET https://accounts.google.com/.well-known/openid-configuration
Response (key fields):
{
"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",
"scopes_supported": ["openid", "email", "profile"],
"response_types_supported": ["code", "token", "id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
}
7. SSO (Single Sign-On)
7.1 SSO Concept
A mechanism to access multiple services with a single login.
Example:
Logging into Google gives you access to Gmail, Drive, YouTube, Calendar.
Pros:
- Improved user experience (single login)
- Reduced password fatigue
- Centralized user management
- Consistent security policies
Cons:
- Single point of failure (IdP outage affects all services)
- Implementation complexity
- Dependency on IdP
7.2 SAML 2.0 vs OIDC
| Feature | SAML 2.0 | OIDC |
|---|---|---|
| Data Format | XML | JSON/JWT |
| Transport | HTTP Redirect/POST | HTTP Redirect |
| Token | Assertion (XML) | ID Token (JWT) |
| Primary Use | Enterprise SSO | Web/Mobile apps |
| Complexity | High | Low |
| Mobile Support | Limited | Excellent |
| Standard Year | 2005 | 2014 |
SAML 2.0 Flow:
1. User accesses SP (Service Provider)
2. SP generates SAML Request -> redirects to IdP
3. User authenticates at IdP
4. IdP generates SAML Assertion (XML) -> POSTs to SP
5. SP validates Assertion -> creates session
OIDC Flow:
1. User accesses app
2. App redirects Authorization request to IdP
3. User authenticates at IdP
4. IdP returns Authorization Code
5. App exchanges Code for ID Token + Access Token
8. Social Login Implementation
8.1 Google Login (Next.js + NextAuth.js)
// app/api/auth/[...nextauth]/route.js
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';
const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token;
token.provider = account.provider;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken;
session.provider = token.provider;
return session;
},
},
pages: {
signIn: '/auth/signin',
error: '/auth/error',
},
});
export { handler as GET, handler as POST };
8.2 GitHub Login (Express.js + Passport.js)
const passport = require('passport');
const GitHubStrategy = require('passport-github2').Strategy;
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: 'https://myapp.com/auth/github/callback'
},
async (accessToken, refreshToken, profile, done) => {
let user = await db.findUserByGitHubId(profile.id);
if (!user) {
user = await db.createUser({
githubId: profile.id,
name: profile.displayName,
email: profile.emails[0].value,
avatar: profile.photos[0].value,
});
}
return done(null, user);
}
));
// Routes
app.get('/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
8.3 Apple Login Specifics
Apple Login differs from other social logins:
1. Hide My Email (Private Email Relay)
- User can provide a unique relay address instead of real email
- Example: abc123@privaterelay.appleid.com
2. User information provided only on first login
- Name and email are returned only on first login
- You MUST save them on the first response!
3. client_secret is a JWT
- Unlike other providers, you generate client_secret as a JWT
- Signed with keys from Apple Developer Portal
4. Web uses Form POST method
- Callback is delivered via POST
9. Passkey / WebAuthn / FIDO2
9.1 What is Passkey
The future of passwordless authentication.
Log in with biometrics (fingerprint/face), device PIN, or security keys.
Passkey = WebAuthn + FIDO2 + Cloud Sync
Pros:
- Phishing-proof (bound to domain)
- No password leaks
- Improved user experience (login with just touch/face)
- Cross-device support (iCloud Keychain, Google Password Manager)
Browser Support:
- Chrome 108+ (2022.12)
- Safari 16+ (2022.09)
- Firefox 122+ (2024.01)
- Edge 108+ (2022.12)
9.2 Registration Flow
// Server: Generate Registration Options
app.post('/webauthn/register/options', async (req, res) => {
const user = req.user;
const options = {
challenge: crypto.randomBytes(32),
rp: {
name: 'My App',
id: 'myapp.com'
},
user: {
id: Buffer.from(user.id),
name: user.email,
displayName: user.name
},
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256
{ alg: -257, type: 'public-key' } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'preferred',
residentKey: 'required'
},
timeout: 60000
};
req.session.challenge = options.challenge;
res.json(options);
});
// Client: Create Passkey
const credential = await navigator.credentials.create({
publicKey: registrationOptions
});
// Server: Complete Registration
app.post('/webauthn/register/verify', async (req, res) => {
const { credential } = req.body;
// Verify challenge, verify origin, extract and save public key
const verified = await verifyRegistration(credential, req.session.challenge);
if (verified) {
await db.saveCredential({
credentialId: credential.id,
publicKey: verified.publicKey,
userId: req.user.id,
counter: verified.counter
});
res.json({ success: true });
}
});
9.3 Authentication Flow
// Server: Generate Authentication Options
app.post('/webauthn/login/options', async (req, res) => {
const options = {
challenge: crypto.randomBytes(32),
rpId: 'myapp.com',
userVerification: 'preferred',
timeout: 60000
};
req.session.challenge = options.challenge;
res.json(options);
});
// Client: Authenticate with Passkey
const assertion = await navigator.credentials.get({
publicKey: authenticationOptions
});
// Server: Verify Authentication
app.post('/webauthn/login/verify', async (req, res) => {
const { assertion } = req.body;
const credential = await db.findCredential(assertion.id);
const verified = await verifyAuthentication(
assertion,
credential.publicKey,
req.session.challenge,
credential.counter
);
if (verified) {
// Update counter (replay attack prevention)
await db.updateCounter(assertion.id, verified.newCounter);
// Issue session/token
const token = jwt.sign({ sub: credential.userId }, privateKey);
res.json({ token });
}
});
10. Multi-Factor Authentication (MFA)
10.1 MFA Method Comparison
| Method | Security Level | UX | Implementation |
|---|---|---|---|
| SMS OTP | Low (SIM swapping) | OK | Low |
| TOTP (Google Authenticator) | Medium | OK | Medium |
| Push Notification (Duo, Okta) | High | Good | High |
| Hardware Key (YubiKey) | Very High | OK | Medium |
| Passkey | Very High | Excellent | Medium |
10.2 TOTP Implementation
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// MFA Setup - Generate secret
app.post('/mfa/setup', async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`,
issuer: 'MyApp'
});
// Generate QR code
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
// Temporarily save secret (before verification)
await db.saveTempMfaSecret(req.user.id, secret.base32);
res.json({
qrCode: qrCodeUrl,
manualEntry: secret.base32
});
});
// MFA Setup - Verify code and activate
app.post('/mfa/verify-setup', async (req, res) => {
const { code } = req.body;
const secret = await db.getTempMfaSecret(req.user.id);
const verified = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: code,
window: 1 // Allow 30 seconds before/after
});
if (verified) {
await db.enableMfa(req.user.id, secret);
// Generate backup codes
const backupCodes = Array.from({ length: 10 }, () =>
crypto.randomBytes(4).toString('hex')
);
await db.saveBackupCodes(req.user.id, backupCodes);
res.json({ success: true, backupCodes });
} else {
res.status(400).json({ error: 'Invalid code' });
}
});
// MFA verification during login
app.post('/auth/mfa-verify', async (req, res) => {
const { code, userId } = req.body;
const user = await db.getUser(userId);
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: code,
window: 1
});
if (verified) {
const token = jwt.sign({ sub: user.id, mfa: true }, privateKey);
res.json({ token });
} else {
res.status(401).json({ error: 'Invalid MFA code' });
}
});
11. Security Best Practices
11.1 CSRF Prevention
CSRF (Cross-Site Request Forgery):
A malicious site forges requests using an authenticated user's privileges.
Defense methods:
1. SameSite cookie attribute
Set-Cookie: session=abc; SameSite=Strict
2. CSRF Tokens
- Server issues a unique token with the form
- Token included with form submission
- Server validates the token
3. Double Submit Cookie
- Include CSRF token in both cookie and request body
- Verify both values match
4. Origin/Referer validation
- Check that the request Origin header is from an allowed domain
11.2 XSS Prevention and Token Storage
Token storage security comparison by location:
localStorage:
- Vulnerable to XSS (accessible via JavaScript)
- Safe from CSRF
- Strongly not recommended
sessionStorage:
- Vulnerable to XSS
- Not shared between tabs
- Slightly better than localStorage
HttpOnly Cookie:
- Safe from XSS (JavaScript cannot access)
- Needs CSRF protection (SameSite=Strict)
- Most recommended approach
Memory (variable):
- Safest from both XSS/CSRF
- Lost on page refresh
- Usable in SPAs, Refresh Token in HttpOnly cookie
11.3 CORS Configuration
// Express.js CORS configuration
const cors = require('cors');
// Bad: Allow all domains
app.use(cors());
// Good: Allow only specific domains
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allow cookies
maxAge: 86400 // Preflight cache 24 hours
}));
11.4 Security Headers
# Essential security headers
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Content-Security-Policy: default-src 'self'; script-src 'self'
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
11.5 Password Policy
// Password hashing with bcrypt
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
// Hash password
async function hashPassword(plainPassword) {
return await bcrypt.hash(plainPassword, SALT_ROUNDS);
}
// Verify password
async function verifyPassword(plainPassword, hashedPassword) {
return await bcrypt.compare(plainPassword, hashedPassword);
}
// Password strength validation
function validatePasswordStrength(password) {
const rules = [
{ test: /.{8,}/, message: 'Minimum 8 characters' },
{ test: /[A-Z]/, message: 'At least 1 uppercase letter' },
{ test: /[a-z]/, message: 'At least 1 lowercase letter' },
{ test: /[0-9]/, message: 'At least 1 number' },
{ test: /[^A-Za-z0-9]/, message: 'At least 1 special character' },
];
const failures = rules.filter(r => !r.test.test(password));
return { valid: failures.length === 0, failures };
}
12. Auth Libraries Comparison
12.1 Major Libraries
| Library | Type | Language | Primary Use Case |
|---|---|---|---|
| NextAuth.js (Auth.js) | Library | JS/TS | Next.js social login |
| Passport.js | Middleware | JS | Express multi-strategy |
| Lucia | Library | JS/TS | Session-based, direct control |
| Clerk | BaaS | JS/TS | Full-stack auth UI |
| Auth0 | IDaaS | Various | Enterprise SSO |
| Keycloak | Self-hosted | Java | Enterprise IdP |
| Supabase Auth | BaaS | JS/TS | Supabase ecosystem |
| Firebase Auth | BaaS | Various | Google ecosystem |
12.2 Selection Criteria
Small project + quick implementation:
-> NextAuth.js / Clerk / Supabase Auth
Direct control + custom requirements:
-> Lucia / Passport.js
Enterprise SSO + SAML:
-> Auth0 / Keycloak
Microservices + self-hosting:
-> Keycloak
Serverless + Google ecosystem:
-> Firebase Auth
13. Session vs JWT Comparison Table
| Dimension | Session-based | JWT-based |
|---|---|---|
| State Management | Stateful (server-stored) | Stateless (self-contained token) |
| Storage Location | Server (Redis/DB) | Client (cookie/memory) |
| Scalability | Requires session sharing | No cross-server sharing needed |
| Security (on theft) | Immediate server invalidation | Valid until expiration |
| Size | Cookie: small (session ID) | Token: large (includes claims) |
| Microservices | Central session store needed | Independent verification per service |
| Mobile Support | Cookie management needed | Authorization header |
| Logout | Delete session (instant) | Blacklist needed |
| Server Load | DB/Redis lookup per request | Signature verification only (CPU) |
| Cross-domain | Cookie domain restrictions | Headers pass freely |
| Implementation | Low complexity | Medium (Refresh Token, etc.) |
| CSRF | Vulnerable (auto cookie send) | Safe (Authorization header) |
| XSS | Safe (HttpOnly cookie) | Vulnerable (if in localStorage) |
| Offline Use | Not possible | Possible (info in token) |
| Debugging | Check server logs | Decode at jwt.io |
14. Quiz
Q1. Authentication vs Authorization
Explain the difference between 401 Unauthorized and 403 Forbidden with specific scenarios.
Answer:
-
401 Unauthorized (Authentication failure): The user has not proven their identity. Examples: API call without Authorization header, using an expired JWT token, wrong password. "I don't know who you are."
-
403 Forbidden (Authorization failure): The user is authenticated but lacks permission for the resource. Examples: regular user calling DELETE /admin/users API, attempting to access another user's private data. "I know who you are, but you don't have permission for this."
Q2. JWT Security
Explain why JWT should not be stored in localStorage and the recommended storage approach.
Answer:
Storing in localStorage is vulnerable to XSS (Cross-Site Scripting) attacks. If a malicious script is injected, it can steal the token via localStorage.getItem('token').
Recommended approach:
- Access Token: Store in memory (JavaScript variable). Lost on page refresh but most secure.
- Refresh Token: Store in HttpOnly + Secure + SameSite=Strict cookie. Inaccessible from JavaScript, safe from XSS.
- When Access Token expires, renew using the Refresh Token cookie.
Q3. OAuth 2.0 PKCE
Explain why PKCE is mandatory for Authorization Code Flow in SPAs.
Answer: SPAs have their source code fully exposed in the browser, making it impossible to securely store a client_secret. Without PKCE, if an Authorization Code is intercepted, an attacker can exchange it for an Access Token.
PKCE solves this:
- Client generates a
code_verifier(random string) - Sends
code_challenge = SHA256(code_verifier)with the authorization request - Sends the original
code_verifierduring token exchange - Server verifies
SHA256(code_verifier) == code_challenge
Even if an attacker intercepts the Authorization Code, they cannot exchange it without the code_verifier.
Q4. Passkey
Explain 3 reasons why Passkey is more secure than passwords.
Answer:
-
Phishing-proof: Passkeys are bound to the registered domain (RP ID). They will not work on fake sites, fundamentally blocking phishing attacks.
-
Safe from server breaches: Only public keys are stored on the server. Even if the DB is leaked, public keys cannot be used to forge authentication. Passwords, while hashed, can be cracked if weak.
-
No reuse: Unique key pairs are generated for each service. A compromise at one service has no impact on others. In contrast, users frequently reuse passwords across multiple sites.
Q5. Session vs JWT
Explain why JWT is advantageous over session-based auth in a microservices environment.
Answer: Why JWT is advantageous in microservices:
-
Independent verification: Each service can independently verify tokens with just the public key. Sessions require all services to share a central session store (Redis).
-
Reduced network overhead: JWT completes authentication with just signature verification, no DB/Redis lookup needed per request.
-
Easy inter-service propagation: Simply pass the JWT in the Authorization header between services. User information is embedded in the token, eliminating additional lookups.
-
Easy scaling: Adding new service instances requires no session store connection setup, just public key distribution.
References
- OAuth 2.0 RFC 6749 - https://tools.ietf.org/html/rfc6749
- JWT RFC 7519 - https://tools.ietf.org/html/rfc7519
- PKCE RFC 7636 - https://tools.ietf.org/html/rfc7636
- OpenID Connect Core - https://openid.net/specs/openid-connect-core-1_0.html
- WebAuthn Specification - https://www.w3.org/TR/webauthn-2/
- FIDO2 Specifications - https://fidoalliance.org/fido2/
- OWASP Authentication Cheat Sheet - https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Session Management - https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- Auth0 Documentation - https://auth0.com/docs
- NextAuth.js (Auth.js) Docs - https://authjs.dev/
- Passkeys.dev - https://passkeys.dev/
- SAML 2.0 Technical Overview - https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html
- OAuth 2.0 Security Best Current Practice - https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
- Keycloak Documentation - https://www.keycloak.org/documentation