OAuth 2.0과 JWT 인증 구현 가이드

인증과 인가의 차이

인증(Authentication)은 **“당신이 누구인가?”**를 확인하는 과정이고, 인가(Authorization)는 **“당신이 무엇을 할 수 있는가?”**를 결정하는 과정입니다. 호텔에 비유하면, 프론트에서 신분증을 확인하는 것이 인증이고, 카드키로 특정 층/방에만 접근 가능한 것이 인가입니다.

개념인증 (AuthN)인가 (AuthZ)
질문누구인가?무엇을 할 수 있는가?
시점로그인 시리소스 접근 시
결과사용자 식별 정보권한/역할
예시ID/PW, OAuthRBAC, ABAC

JWT (JSON Web Token) 구조

JWT는 세 부분으로 구성됩니다: Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.    ← Header (알고리즘, 타입)
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik  ← Payload (클레임, 사용자 정보)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ  ← Signature (서명, 위변조 방지)

JWT 생성과 검증

import jwt from "jsonwebtoken";

const JWT_SECRET = process.env.JWT_SECRET; // 환경변수에서 시크릿 로드

// JWT 토큰 생성
function generateTokens(user) {
  // Access Token — 짧은 수명 (15분)
  const accessToken = jwt.sign(
    {
      sub: user.id,           // 사용자 고유 ID
      email: user.email,
      role: user.role,        // 권한 정보
      type: "access"
    },
    JWT_SECRET,
    { expiresIn: "15m" }      // 15분 후 만료
  );

  // Refresh Token — 긴 수명 (7일)
  const refreshToken = jwt.sign(
    {
      sub: user.id,
      type: "refresh"
    },
    JWT_SECRET,
    { expiresIn: "7d" }       // 7일 후 만료
  );

  return { accessToken, refreshToken };
}

// JWT 토큰 검증
function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    return { valid: true, payload: decoded };
  } catch (error) {
    if (error.name === "TokenExpiredError") {
      return { valid: false, error: "TOKEN_EXPIRED" };
    }
    return { valid: false, error: "INVALID_TOKEN" };
  }
}

// 사용 예시
const user = { id: "user_123", email: "hong@example.com", role: "admin" };
const tokens = generateTokens(user);
console.log("Access:", tokens.accessToken);
// 출력: Access: eyJhbGciOiJIUzI1NiIs...

const result = verifyToken(tokens.accessToken);
console.log("검증:", result);
// 출력: 검증: { valid: true, payload: { sub: 'user_123', ... } }

미들웨어 구현

// Express.js 인증 미들웨어
function authMiddleware(req, res, next) {
  // Authorization 헤더에서 토큰 추출
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({
      error: { code: "NO_TOKEN", message: "인증 토큰이 필요합니다." }
    });
  }

  const token = authHeader.split(" ")[1];
  const result = verifyToken(token);

  if (!result.valid) {
    const status = result.error === "TOKEN_EXPIRED" ? 401 : 403;
    return res.status(status).json({
      error: { code: result.error, message: "토큰이 유효하지 않습니다." }
    });
  }

  // 검증된 사용자 정보를 요청 객체에 추가
  req.user = result.payload;
  next();
}

// 역할 기반 인가 미들웨어
function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        error: { code: "FORBIDDEN", message: "접근 권한이 없습니다." }
      });
    }
    next();
  };
}

// 라우트에 적용
app.get("/api/users", authMiddleware, (req, res) => {
  // 인증된 사용자만 접근 가능
  res.json({ users: [...] });
});

app.delete("/api/users/:id",
  authMiddleware,
  requireRole("admin"),  // admin 역할만 접근 가능
  (req, res) => {
    // 관리자만 사용자 삭제 가능
    res.json({ message: "삭제 완료" });
  }
);

OAuth 2.0 흐름

OAuth 2.0은 제3자 서비스를 통한 인증 위임 프로토콜입니다. “Google로 로그인” 같은 소셜 로그인이 대표적입니다.

Authorization Code Flow (가장 안전, 권장)

1. 사용자 → 클라이언트: "Google로 로그인" 클릭
2. 클라이언트 → Google: 인증 페이지로 리다이렉트
3. 사용자 → Google: 로그인 + 권한 동의
4. Google → 클라이언트: Authorization Code 전달 (리다이렉트)
5. 클라이언트 서버 → Google: Code + Client Secret으로 Token 요청
6. Google → 클라이언트 서버: Access Token + Refresh Token 발급
7. 클라이언트 서버 → Google API: Access Token으로 사용자 정보 조회

구현 예시 (Express + Passport)

import express from "express";
import passport from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth20";

// Google OAuth 전략 설정
passport.use(new GoogleStrategy(
  {
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: "/auth/google/callback"
  },
  async (accessToken, refreshToken, profile, done) => {
    // Google 프로필로 사용자 조회 또는 생성
    let user = await findUserByGoogleId(profile.id);
    if (!user) {
      user = await createUser({
        googleId: profile.id,
        email: profile.emails[0].value,
        name: profile.displayName,
        avatar: profile.photos[0].value
      });
    }
    return done(null, user);
  }
));

const app = express();

// OAuth 로그인 시작 — Google 인증 페이지로 리다이렉트
app.get("/auth/google",
  passport.authenticate("google", {
    scope: ["profile", "email"]  // 요청할 권한 범위
  })
);

// OAuth 콜백 — Google에서 인증 완료 후 리다이렉트
app.get("/auth/google/callback",
  passport.authenticate("google", { session: false }),
  (req, res) => {
    // JWT 토큰 발급
    const tokens = generateTokens(req.user);

    // httpOnly 쿠키로 Refresh Token 설정
    res.cookie("refresh_token", tokens.refreshToken, {
      httpOnly: true,       // JavaScript 접근 불가 (XSS 방어)
      secure: true,         // HTTPS에서만 전송
      sameSite: "strict",   // CSRF 방어
      maxAge: 7 * 24 * 60 * 60 * 1000  // 7일
    });

    // Access Token은 응답 본문으로
    res.json({ accessToken: tokens.accessToken });
  }
);

Token Refresh 전략

// Refresh Token으로 Access Token 재발급
app.post("/auth/refresh", (req, res) => {
  const refreshToken = req.cookies.refresh_token;

  if (!refreshToken) {
    return res.status(401).json({
      error: { code: "NO_REFRESH_TOKEN", message: "재로그인이 필요합니다." }
    });
  }

  const result = verifyToken(refreshToken);
  if (!result.valid || result.payload.type !== "refresh") {
    // 쿠키 삭제 후 재로그인 유도
    res.clearCookie("refresh_token");
    return res.status(401).json({
      error: { code: "INVALID_REFRESH", message: "재로그인이 필요합니다." }
    });
  }

  // 새 토큰 쌍 발급 (Refresh Token Rotation)
  const user = { id: result.payload.sub };
  const tokens = generateTokens(user);

  // 새 Refresh Token으로 쿠키 갱신
  res.cookie("refresh_token", tokens.refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  res.json({ accessToken: tokens.accessToken });
});

보안 체크리스트

항목권장 사항위험
Access Token 저장메모리 (변수)localStorage는 XSS에 취약
Refresh Token 저장httpOnly 쿠키JavaScript 접근 불가
Token 수명Access: 15분, Refresh: 7일너무 길면 탈취 위험
서명 알고리즘RS256 (비대칭) 또는 HS256none 알고리즘 허용 금지
CORS 설정허용 도메인 명시* (와일드카드) 금지
HTTPS필수HTTP에서 토큰 탈취 가능

실전 팁

  • Access Token은 짧게, Refresh Token은 길게 설정하세요: Access Token이 탈취되더라도 15분이면 만료됩니다.
  • Refresh Token Rotation을 적용하세요: Refresh Token 사용 시 새 쌍을 발급하면, 이전 토큰이 탈취되더라도 한 번만 사용 가능합니다.
  • JWT에 민감 정보를 넣지 마세요: Payload는 Base64 인코딩일 뿐 암호화가 아닙니다. 비밀번호, 주민번호 등을 포함하지 마세요.
  • 로그아웃 시 토큰 블랙리스트를 관리하세요: JWT는 서버에서 무효화할 수 없으므로, Redis 등에 블랙리스트를 유지하세요.
  • PKCE(Proof Key for Code Exchange)를 사용하세요: SPA나 모바일 앱에서 OAuth를 사용할 때 Authorization Code 가로채기 공격을 방지합니다.

이 글이 도움이 되었나요?