Skip to content

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

✨ Learn with Quiz
|

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

서론

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

OAuth 2.0 & Authentication Complete Guide 2025: JWT, Sessions, SSO, OIDC, Passkey

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

StoreProsConsBest For
MemoryFastestLost on restart, not sharedDevelopment
RedisFast, shared, TTL supportExtra infrastructureProduction (recommended)
DB (PostgreSQL)Persistent, auditableSlowAudit requirements
FileSimple implementationSlow, not sharedSmall 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

AlgorithmTypeKeyUse Cases
HS256SymmetricSingle secret keySingle server, internal services
RS256AsymmetricPublic/private key pairMicroservices, external verification
ES256Asymmetric (ECDSA)Shorter key lengthMobile, 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

FeatureSAML 2.0OIDC
Data FormatXMLJSON/JWT
TransportHTTP Redirect/POSTHTTP Redirect
TokenAssertion (XML)ID Token (JWT)
Primary UseEnterprise SSOWeb/Mobile apps
ComplexityHighLow
Mobile SupportLimitedExcellent
Standard Year20052014
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

MethodSecurity LevelUXImplementation
SMS OTPLow (SIM swapping)OKLow
TOTP (Google Authenticator)MediumOKMedium
Push Notification (Duo, Okta)HighGoodHigh
Hardware Key (YubiKey)Very HighOKMedium
PasskeyVery HighExcellentMedium

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

LibraryTypeLanguagePrimary Use Case
NextAuth.js (Auth.js)LibraryJS/TSNext.js social login
Passport.jsMiddlewareJSExpress multi-strategy
LuciaLibraryJS/TSSession-based, direct control
ClerkBaaSJS/TSFull-stack auth UI
Auth0IDaaSVariousEnterprise SSO
KeycloakSelf-hostedJavaEnterprise IdP
Supabase AuthBaaSJS/TSSupabase ecosystem
Firebase AuthBaaSVariousGoogle 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

DimensionSession-basedJWT-based
State ManagementStateful (server-stored)Stateless (self-contained token)
Storage LocationServer (Redis/DB)Client (cookie/memory)
ScalabilityRequires session sharingNo cross-server sharing needed
Security (on theft)Immediate server invalidationValid until expiration
SizeCookie: small (session ID)Token: large (includes claims)
MicroservicesCentral session store neededIndependent verification per service
Mobile SupportCookie management neededAuthorization header
LogoutDelete session (instant)Blacklist needed
Server LoadDB/Redis lookup per requestSignature verification only (CPU)
Cross-domainCookie domain restrictionsHeaders pass freely
ImplementationLow complexityMedium (Refresh Token, etc.)
CSRFVulnerable (auto cookie send)Safe (Authorization header)
XSSSafe (HttpOnly cookie)Vulnerable (if in localStorage)
Offline UseNot possiblePossible (info in token)
DebuggingCheck server logsDecode 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:

  1. Access Token: Store in memory (JavaScript variable). Lost on page refresh but most secure.
  2. Refresh Token: Store in HttpOnly + Secure + SameSite=Strict cookie. Inaccessible from JavaScript, safe from XSS.
  3. 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:

  1. Client generates a code_verifier (random string)
  2. Sends code_challenge = SHA256(code_verifier) with the authorization request
  3. Sends the original code_verifier during token exchange
  4. 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:

  1. Phishing-proof: Passkeys are bound to the registered domain (RP ID). They will not work on fake sites, fundamentally blocking phishing attacks.

  2. 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.

  3. 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:

  1. Independent verification: Each service can independently verify tokens with just the public key. Sessions require all services to share a central session store (Redis).

  2. Reduced network overhead: JWT completes authentication with just signature verification, no DB/Redis lookup needed per request.

  3. Easy inter-service propagation: Simply pass the JWT in the Authorization header between services. User information is embedded in the token, eliminating additional lookups.

  4. Easy scaling: Adding new service instances requires no session store connection setup, just public key distribution.


References

  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