- Published on
OAuth 2.0 & 인증 완전 가이드 2025: JWT, 세션, SSO, OIDC, Passkey까지
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 서론
- 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