Skip to content
Published on

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

Authors

서론

웹 애플리케이션에서 "인증(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 (하지 말 것):
- JWTlocalStorage저장 (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.0OIDC
데이터 형식XMLJSON/JWT
전송HTTP Redirect/POSTHTTP Redirect
토큰Assertion (XML)ID Token (JWT)
주 사용처엔터프라이즈 SSO웹/모바일 앱
복잡도높음낮음
모바일 지원제한적우수
표준화2005년2014년
SAML 2.0 흐름:
1. 사용자가 SP(Service Provider) 접근
2. SPSAML Request 생성 -> IdP로 리다이렉트
3. 사용자가 IdP에서 인증
4. IdP가 SAML Assertion(XML) 생성 -> SPPOST
5. SPAssertion 검증 -> 세션 생성

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/TSNext.js 소셜 로그인
Passport.js미들웨어JSExpress 다양한 전략
Lucia라이브러리JS/TS세션 기반, 직접 제어
ClerkBaaSJS/TS풀스택 인증 UI
Auth0IDaaS다양엔터프라이즈 SSO
Keycloak자체 호스팅Java엔터프라이즈 IdP
Supabase AuthBaaSJS/TSSupabase 생태계
Firebase AuthBaaS다양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')으로 토큰을 탈취할 수 있습니다.

권장 저장 방법:

  1. Access Token: 메모리(JavaScript 변수)에 저장. 페이지 새로고침시 소실되지만 가장 안전.
  2. Refresh Token: HttpOnly + Secure + SameSite=Strict 쿠키에 저장. JavaScript에서 접근 불가하여 XSS에 안전.
  3. Access Token이 만료되면 Refresh Token 쿠키로 갱신.

Q3. OAuth 2.0 PKCE

SPA에서 Authorization Code Flow에 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

Passkey가 비밀번호보다 안전한 이유를 3가지 설명하세요.

정답:

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

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

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

Q5. 세션 vs JWT

마이크로서비스 환경에서 세션 기반 인증보다 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