Skip to content

필사 모드: OAuth 2.0 & 인증 완전 가이드 2025: JWT, 세션, SSO, OIDC, Passkey까지

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

서론

웹 애플리케이션에서 "인증(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

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 (인증 실패):** 사용자가 자신을 증명하지 않은 상태. 예: Authorization 헤더 없이 API 호출, 만료된 JWT 토큰 사용, 잘못된 비밀번호 입력. "당신이 누군지 모릅니다."

- **403 Forbidden (인가 실패):** 사용자는 인증되었지만 해당 리소스에 대한 권한이 없는 상태. 예: 일반 사용자가 DELETE /admin/users API를 호출, 다른 사용자의 비공개 데이터 접근 시도. "당신이 누군지는 알지만 이 작업을 할 권한이 없습니다."

Q2. JWT 보안

**정답:**

localStorage에 저장하면 XSS(Cross-Site Scripting) 공격에 취약합니다. 악성 스크립트가 주입되면 `localStorage.getItem('token')`으로 토큰을 탈취할 수 있습니다.

**권장 저장 방법:**

1. **Access Token:** 메모리(JavaScript 변수)에 저장. 페이지 새로고침시 소실되지만 가장 안전.

2. **Refresh Token:** HttpOnly + Secure + SameSite=Strict 쿠키에 저장. JavaScript에서 접근 불가하여 XSS에 안전.

3. Access Token이 만료되면 Refresh Token 쿠키로 갱신.

Q3. OAuth 2.0 PKCE

**정답:**

SPA는 소스코드가 브라우저에 완전히 노출되므로 client_secret을 안전하게 보관할 수 없습니다. PKCE 없이는 Authorization Code가 가로채지면 공격자가 이를 Access Token으로 교환할 수 있습니다.

PKCE는 이 문제를 해결합니다:

1. 클라이언트가 `code_verifier`(랜덤 문자열)를 생성

2. `code_challenge = SHA256(code_verifier)`를 인가 요청에 포함

3. 토큰 교환시 원본 `code_verifier`를 전송

4. 서버가 `SHA256(code_verifier) == code_challenge`를 검증

공격자가 Authorization Code를 가로채도 `code_verifier`를 모르면 토큰 교환이 불가능합니다.

Q4. Passkey

**정답:**

1. **피싱 불가능:** Passkey는 등록된 도메인(RP ID)에 바인딩됩니다. 가짜 사이트에서는 Passkey가 작동하지 않으므로 피싱 공격이 원천적으로 차단됩니다.

2. **서버 유출 안전:** 서버에는 공개키만 저장됩니다. DB가 유출되어도 공개키로는 인증을 위조할 수 없습니다. 비밀번호는 해시되어 저장되지만 취약한 비밀번호는 크랙될 수 있습니다.

3. **재사용 불가:** 각 서비스마다 고유한 키 쌍이 생성됩니다. 하나의 서비스에서 유출되어도 다른 서비스에 영향이 없습니다. 반면 비밀번호는 사용자들이 여러 사이트에서 재사용하는 경우가 많습니다.

Q5. 세션 vs JWT

**정답:**

마이크로서비스 환경에서 JWT가 유리한 이유:

1. **독립적 검증:** 각 서비스가 공개키만 있으면 독립적으로 토큰을 검증할 수 있습니다. 세션은 중앙 세션 저장소(Redis)를 모든 서비스가 공유해야 합니다.

2. **네트워크 부하 감소:** JWT는 서명 검증만으로 인증이 완료되어 매 요청마다 DB/Redis를 조회할 필요가 없습니다.

3. **서비스 간 전파 용이:** JWT를 Authorization 헤더에 포함하여 서비스 간 전달만 하면 됩니다. 사용자 정보가 토큰 내에 포함되어 있어 추가 조회가 불필요합니다.

4. **스케일링 용이:** 새 서비스 인스턴스 추가시 세션 저장소 연결 설정이 불필요하며, 공개키 배포만 하면 됩니다.

참고 자료

1. OAuth 2.0 RFC 6749 - https://tools.ietf.org/html/rfc6749

2. JWT RFC 7519 - https://tools.ietf.org/html/rfc7519

3. PKCE RFC 7636 - https://tools.ietf.org/html/rfc7636

4. OpenID Connect Core - https://openid.net/specs/openid-connect-core-1_0.html

5. WebAuthn Specification - https://www.w3.org/TR/webauthn-2/

6. FIDO2 Specifications - https://fidoalliance.org/fido2/

7. OWASP Authentication Cheat Sheet - https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html

8. OWASP Session Management - https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html

9. Auth0 Documentation - https://auth0.com/docs

10. NextAuth.js (Auth.js) Docs - https://authjs.dev/

11. Passkeys.dev - https://passkeys.dev/

12. SAML 2.0 Technical Overview - https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html

13. OAuth 2.0 Security Best Current Practice - https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics

14. Keycloak Documentation - https://www.keycloak.org/documentation

현재 단락 (1/792)

웹 애플리케이션에서 "인증(Authentication)"과 "인가(Authorization)"는 보안의 핵심입니다. 사용자 로그인부터 API 접근 제어, 마이크로서비스 간 통신까지...

작성 글자: 0원문 글자: 21,539작성 단락: 0/792