Next.js 19편의 Auth.js 가 같은 사이트 안 인증의 답이라면, JWT(JSON Web Token) 는 그 범위 밖 — 모바일 앱 API, 외부 서비스 발급, 마이크로서비스 간 호출에서 표준. Node 직접 구현이 표준이다.
이번 편은 JWT 의 정체·로그인 흐름·실전 구현·refresh 패턴까지. 보안 사고 줄이는 5가지 함정도.
1. JWT 의 정체 — 점 두 개로 나뉜 문자열
JWT 는 그냥 3 부분이 점으로 연결된 문자열.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MTAwMDAwMDB9.X3sigX
└─ header ─┘.└─────── payload ─────────┘.└─signature─┘
각 부분의 정체:
| 부분 | 내용 | 인코딩 |
| header | {"alg":"HS256"} | Base64url |
| payload | {"sub":"123","exp":1710000000,...} | Base64url |
| signature | HMAC(header.payload, SECRET) | Base64url |
가장 큰 오해 — 암호화 아님 — JWT payload 는 그냥 Base64. 누구나 디코딩해 내용을 볼 수 있다 (11편 Buffer 챕터의 base64 toString 한 줄). signature 는 위변조 방지일 뿐, 비밀 데이터 숨기는 게 아님. 비밀번호·민감 정보를 payload 에 절대 넣지 말 것.
2. 설치와 헬퍼
$ npm install bcrypt jsonwebtoken
$ npm install -D @types/bcrypt @types/jsonwebtoken
// auth/tokens.js
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET; // 17편 검증된 값
const EXP = process.env.JWT_EXPIRES || '15m';
export function signAccessToken(userId) {
return jwt.sign({ sub: userId }, SECRET, { expiresIn: EXP });
}
export function verifyToken(token) {
try {
return jwt.verify(token, SECRET); // 만료·서명 자동 검증
} catch {
return null;
}
}
sign 으로 발급, verify 로 검증. 검증 실패(만료·잘못된 서명) 는 throw 라 try/catch 또는 null 반환.
3. 로그인·발급 — bcrypt 해시와 함께
비밀번호는 절대 평문 저장 금지. bcrypt 로 해시.
// 회원가입
import bcrypt from 'bcrypt';
import { pool } from '@/db';
router.post('/signup', async (req, res) => {
const { email, password } = req.body;
const hash = await bcrypt.hash(password, 12); // 12 라운드 권장
const { rows: [user] } = await pool.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email',
[email, hash]
);
res.status(201).json(user);
});
// 로그인
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const { rows: [user] } = await pool.query(
'SELECT id, password_hash FROM users WHERE email = $1',
[email]
);
if (!user) return res.status(401).json({ error: '잘못된 자격' });
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ error: '잘못된 자격' });
const token = signAccessToken(user.id);
res.json({ token });
});
왜 bcrypt 12 라운드? — 라운드 수가 높을수록 무차별 대입 공격이 느려진다. 12 는 2026 기준 표준 (서버에서 ~250ms). 너무 낮으면(8) 보안 약함, 너무 높으면(15) 사용자가 로그인 1초 기다림. 12 가 스위트 스폿. 라이브러리 옵션 argon2id 가 더 안전한 후속이지만 bcrypt 가 호환성 좋음.
4. 검증 미들웨어 — Express 가드
14편의 미들웨어 패턴 응용.
// middleware/auth.js
import { verifyToken } from './tokens.js';
export function requireAuth(req, res, next) {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
return res.status(401).json({ error: '토큰 없음' });
}
const payload = verifyToken(auth.slice(7));
if (!payload) {
return res.status(401).json({ error: '만료 또는 잘못된 토큰' });
}
req.user = { id: payload.sub };
next();
}
// 사용
router.get('/me', requireAuth, (req, res) => {
res.json({ id: req.user.id });
});
클라이언트는 매 요청 Authorization: Bearer eyJ... 헤더로 토큰을 보낸다. 표준 HTTP 인증 방식.
5. Refresh 토큰 — 짧은 access + 긴 refresh
한 토큰만 쓰면 — 길게 두면 탈취 위험, 짧게 두면 사용자가 매 시간 재로그인. 답이 2 토큰.
| 토큰 | 수명 | 저장 위치 | 용도 |
| Access | 15분 | 메모리·localStorage | 매 API 요청 |
| Refresh | 14일 | httpOnly 쿠키 | access 만료 시 갱신 |
access 가 만료되면 클라이언트가 POST /auth/refresh 호출 → 서버가 refresh 토큰(쿠키) 검증 → 새 access 발급. 탈취돼도 refresh 는 httpOnly 라 JS 가 못 읽어 XSS 로 못 빼간다.
// 로그인 시 둘 다 발급
const access = signAccessToken(user.id); // 15m
const refresh = signRefreshToken(user.id); // 14d
res.cookie('refreshToken', refresh, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 14 * 24 * 60 * 60 * 1000,
});
res.json({ access }); // 본문엔 access 만
흔한 함정 5가지 — ① 비밀번호 평문 저장(범죄) ② payload 에 비밀 넣음(노출됨) ③ refresh 토큰 localStorage 저장(XSS 취약) ④ sameSite 안 설정(CSRF) ⑤ HS256 secret 짧음(32자+ 필수). 한 줄로 사고 가능. 처음부터 정석으로.
요약 — 19편 좌표
여기까지 정리. JWT 는 3부분 Base64 문자열 — payload 는 누구나 디코딩 가능, 비밀 X. 비밀번호는 bcrypt 12 라운드로 해시. 로그인 시 access(15분) + refresh(14일·httpOnly 쿠키) 두 토큰 발급. 미들웨어로 Authorization: Bearer 검증. 5가지 함정만 피하면 표준 인증 시스템 완성. 다음 편에서 파일 업로드로 멀티파트 폼.
다음 편 예고 — 파일 업로드
multer 로 이미지·파일 받기. 20편.