인증과 인가의 차이
인증(Authentication)은 **“당신이 누구인가?”**를 확인하는 과정이고, 인가(Authorization)는 **“당신이 무엇을 할 수 있는가?”**를 결정하는 과정입니다. 호텔에 비유하면, 프론트에서 신분증을 확인하는 것이 인증이고, 카드키로 특정 층/방에만 접근 가능한 것이 인가입니다.
| 개념 | 인증 (AuthN) | 인가 (AuthZ) |
|---|---|---|
| 질문 | 누구인가? | 무엇을 할 수 있는가? |
| 시점 | 로그인 시 | 리소스 접근 시 |
| 결과 | 사용자 식별 정보 | 권한/역할 |
| 예시 | ID/PW, OAuth | RBAC, 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 (비대칭) 또는 HS256 | none 알고리즘 허용 금지 |
| 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 가로채기 공격을 방지합니다.