Node.js Express JWT와 MySQL 로그인 구현: Access·Refresh Token 보안
이 글은 2022년의 Node.js·Express·JWT·MySQL 로그인 기록을 보안 중심의 구현 가이드로 전면 개정한 글입니다. 기존 URL은 유지하지만, 인증서 검증을 끄는 방법과 장기 JWT 하나만 사용하는 구조는 제거했습니다.
JWT 로그인 예제는 대개 로그인 성공 후 토큰 하나를 반환하는 데서 끝납니다. 실제 서비스에는 그 뒤의 문제가 더 중요합니다. 토큰이 탈취되면 어떻게 폐기할지, 비밀번호를 어떻게 저장할지, 여러 요청이 동시에 Refresh Token을 재사용하면 어떻게 막을지까지 설계해야 합니다.
이 글에서는 다음 구조를 구현합니다.
- 비밀번호는 단방향 해시로 저장한다.
- Access Token은 짧게 유지하고 API 요청에 사용한다.
- Refresh Token은 충분히 긴 난수로 만들고 원문 대신 해시를 MySQL에 저장한다.
- 갱신할 때 Refresh Token을 한 번만 사용하고 즉시 회전한다.
- 브라우저에는 Refresh Token을
HttpOnly,Secure쿠키로 전달한다. - 로그아웃하면 서버 저장소의 토큰을 폐기한다.
먼저 보는 핵심
JWT라는 형식이 세션 폐기 문제를 자동으로 해결하지는 않습니다. 짧은 Access Token과 서버에서 통제하는 회전형 Refresh Token을 함께 사용해야 탈취와 강제 로그아웃에 대응할 수 있습니다.
| 항목 | Access Token | Refresh Token |
|---|---|---|
| 목적 | 보호된 API 호출 | 새 Access Token 발급 |
| 권장 수명 | 짧게, 예제는 10분 | 더 길게, 예제는 14일 |
| 저장 위치 | 앱 메모리 또는 Authorization 헤더 | 브라우저의 HttpOnly 쿠키 |
| 서버 저장 | 보통 저장하지 않음 | 원문이 아닌 SHA-256 해시 저장 |
| 폐기 | 만료를 짧게 두거나 별도 차단 목록 | DB 행 폐기 또는 삭제 |
패키지와 환경 변수
예제는 ESM 문법을 사용합니다.
npm install express mysql2 jsonwebtoken argon2 cookie-parser helmet express-rate-limit zod
비밀값과 DB 접속 정보는 소스에 넣지 않습니다. Node.js는 .env 파일을 읽는 기능을 제공하므로 실행 환경에 맞춰 주입합니다.
NODE_ENV=development
PORT=3000
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=app_user
DB_PASSWORD=change-me
DB_NAME=auth_example
JWT_SECRET=replace-with-at-least-32-random-bytes
JWT_ISSUER=https://api.example.com
JWT_AUDIENCE=https://app.example.com
운영 환경에서는 저장소의 .env 파일이 아니라 배포 플랫폼의 secret 관리 기능을 사용하세요. JWT secret은 일반 비밀번호처럼 직접 정하지 말고 암호학적으로 안전한 난수로 생성해야 합니다.
node --env-file=.env src/server.js
MySQL 테이블 설계
사용자와 Refresh Token을 분리하면 기기별 로그인과 개별 세션 폐기가 쉬워집니다.
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(320) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_users_email (email)
) ENGINE=InnoDB;
CREATE TABLE refresh_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
token_hash BINARY(32) NOT NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
replaced_by_hash BINARY(32) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_refresh_token_hash (token_hash),
KEY ix_refresh_user (user_id),
CONSTRAINT fk_refresh_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
Refresh Token 원문을 DB에 저장하면 DB 유출 시 즉시 로그인에 악용될 수 있습니다. 서버가 토큰을 다시 표시할 필요는 없으므로 SHA-256 해시만 저장해 비교합니다. 비밀번호에는 빠른 SHA-256이 아니라 공격 비용을 높이는 Argon2id 같은 password hashing algorithm을 사용해야 합니다.
공통 설정
// src/config.js
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().int().positive().default(3000),
DB_HOST: z.string().min(1),
DB_PORT: z.coerce.number().int().positive().default(3306),
DB_USER: z.string().min(1),
DB_PASSWORD: z.string(),
DB_NAME: z.string().min(1),
JWT_SECRET: z.string().min(32),
JWT_ISSUER: z.string().url(),
JWT_AUDIENCE: z.string().url(),
});
export const config = schema.parse(process.env);
// src/db.js
import mysql from 'mysql2/promise';
import { config } from './config.js';
export const pool = mysql.createPool({
host: config.DB_HOST,
port: config.DB_PORT,
user: config.DB_USER,
password: config.DB_PASSWORD,
database: config.DB_NAME,
connectionLimit: 10,
timezone: 'Z',
});
문자열 연결로 SQL을 만들지 말고 항상 placeholder를 사용합니다. 아래 예제의 execute(sql, values)가 그 역할을 합니다.
토큰 함수 만들기
// src/tokens.js
import crypto from 'node:crypto';
import jwt from 'jsonwebtoken';
import { config } from './config.js';
const ACCESS_TTL = '10m';
export const REFRESH_TTL_MS = 14 * 24 * 60 * 60 * 1000;
export function createAccessToken(user) {
return jwt.sign(
{ role: user.role ?? 'user' },
config.JWT_SECRET,
{
algorithm: 'HS256',
subject: String(user.id),
issuer: config.JWT_ISSUER,
audience: config.JWT_AUDIENCE,
expiresIn: ACCESS_TTL,
},
);
}
export function verifyAccessToken(token) {
return jwt.verify(token, config.JWT_SECRET, {
algorithms: ['HS256'],
issuer: config.JWT_ISSUER,
audience: config.JWT_AUDIENCE,
});
}
export function createRefreshToken() {
return crypto.randomBytes(32).toString('base64url');
}
export function hashRefreshToken(token) {
return crypto.createHash('sha256').update(token).digest();
}
검증할 때 허용 algorithm을 명시하고 issuer, audience, expiration을 확인합니다. JWT payload는 암호화된 비밀 공간이 아니므로 비밀번호, 주민번호, API key 같은 정보를 넣으면 안 됩니다.
회원 가입
import argon2 from 'argon2';
import { Router } from 'express';
import { z } from 'zod';
import { pool } from './db.js';
export const authRouter = Router();
const credentialsSchema = z.object({
email: z.string().email().max(320).transform(v => v.toLowerCase()),
password: z.string().min(12).max(128),
});
authRouter.post('/register', async (req, res, next) => {
try {
const { email, password } = credentialsSchema.parse(req.body);
const passwordHash = await argon2.hash(password, {
type: argon2.argon2id,
});
const [result] = await pool.execute(
'INSERT INTO users (email, password_hash) VALUES (?, ?)',
[email, passwordHash],
);
res.status(201).json({ userId: String(result.insertId) });
} catch (error) {
if (error?.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'EMAIL_ALREADY_EXISTS' });
}
next(error);
}
});
중복 이메일의 존재 여부를 지나치게 자세히 노출하면 계정 탐색에 사용될 수 있습니다. 서비스 성격에 따라 가입과 비밀번호 재설정 응답을 더 일반화하고, 이메일 소유 확인 절차를 추가하세요.
로그인과 Refresh Token 쿠키
import {
createAccessToken,
createRefreshToken,
hashRefreshToken,
REFRESH_TTL_MS,
} from './tokens.js';
const refreshCookie = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/auth',
maxAge: REFRESH_TTL_MS,
};
authRouter.post('/login', async (req, res, next) => {
try {
const { email, password } = credentialsSchema.parse(req.body);
const [rows] = await pool.execute(
'SELECT id, email, password_hash FROM users WHERE email = ? LIMIT 1',
[email],
);
const user = rows[0];
const valid = user && await argon2.verify(user.password_hash, password);
if (!valid) {
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
}
const refreshToken = createRefreshToken();
const tokenHash = hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + REFRESH_TTL_MS);
await pool.execute(
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES (?, ?, ?)`,
[user.id, tokenHash, expiresAt],
);
res.cookie('refresh_token', refreshToken, refreshCookie);
res.json({ accessToken: createAccessToken(user) });
} catch (error) {
next(error);
}
});
로그인 endpoint에는 IP와 계정 단위 rate limit을 적용해야 합니다. 단순 IP 제한만 사용하면 공유 네트워크 사용자를 막을 수 있고, 계정만 제한하면 공격자가 여러 계정을 순회할 수 있으므로 서비스 환경에 맞춰 두 기준을 조합합니다.
Refresh Token 회전
갱신 요청은 기존 토큰을 폐기하고 새 토큰을 발급해야 합니다. 조회와 폐기를 하나의 트랜잭션으로 묶고 행을 잠가야 동시에 들어온 두 요청이 같은 토큰을 모두 성공시키는 일을 막을 수 있습니다.
authRouter.post('/refresh', async (req, res, next) => {
const currentToken = req.cookies.refresh_token;
if (!currentToken) {
return res.status(401).json({ error: 'REFRESH_TOKEN_REQUIRED' });
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const currentHash = hashRefreshToken(currentToken);
const [rows] = await connection.execute(
`SELECT id, user_id
FROM refresh_tokens
WHERE token_hash = ?
AND revoked_at IS NULL
AND expires_at > UTC_TIMESTAMP()
FOR UPDATE`,
[currentHash],
);
const session = rows[0];
if (!session) {
await connection.rollback();
res.clearCookie('refresh_token', refreshCookie);
return res.status(401).json({ error: 'INVALID_REFRESH_TOKEN' });
}
const nextToken = createRefreshToken();
const nextHash = hashRefreshToken(nextToken);
const nextExpiresAt = new Date(Date.now() + REFRESH_TTL_MS);
await connection.execute(
`UPDATE refresh_tokens
SET revoked_at = UTC_TIMESTAMP(), replaced_by_hash = ?
WHERE id = ?`,
[nextHash, session.id],
);
await connection.execute(
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES (?, ?, ?)`,
[session.user_id, nextHash, nextExpiresAt],
);
await connection.commit();
res.cookie('refresh_token', nextToken, refreshCookie);
res.json({
accessToken: createAccessToken({ id: session.user_id }),
});
} catch (error) {
await connection.rollback();
next(error);
} finally {
connection.release();
}
});
더 강한 재사용 탐지가 필요하면 토큰 계열을 나타내는 family_id를 추가하세요. 이미 교체된 토큰이 다시 제시되면 탈취 가능성이 있으므로 같은 계열의 모든 세션을 폐기하고 사용자에게 재로그인을 요구할 수 있습니다.
Access Token 인증 middleware
export function requireAuth(req, res, next) {
const authorization = req.get('authorization') ?? '';
const [scheme, token] = authorization.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'ACCESS_TOKEN_REQUIRED' });
}
try {
const payload = verifyAccessToken(token);
req.auth = {
userId: payload.sub,
role: payload.role,
};
next();
} catch {
res.status(401).json({ error: 'INVALID_ACCESS_TOKEN' });
}
}
app.get('/me', requireAuth, async (req, res, next) => {
try {
const [rows] = await pool.execute(
'SELECT id, email, created_at FROM users WHERE id = ?',
[req.auth.userId],
);
if (!rows[0]) return res.sendStatus(404);
res.json(rows[0]);
} catch (error) {
next(error);
}
});
로그아웃
authRouter.post('/logout', async (req, res, next) => {
try {
const token = req.cookies.refresh_token;
if (token) {
await pool.execute(
`UPDATE refresh_tokens
SET revoked_at = UTC_TIMESTAMP()
WHERE token_hash = ? AND revoked_at IS NULL`,
[hashRefreshToken(token)],
);
}
res.clearCookie('refresh_token', refreshCookie);
res.sendStatus(204);
} catch (error) {
next(error);
}
});
Access Token은 최대 10분 동안 남을 수 있습니다. 즉시 차단이 반드시 필요한 서비스라면 사용자 또는 token ID 기반 차단 목록을 추가하거나 서버 세션 방식을 검토해야 합니다.
Express 운영 보안 설정
import cookieParser from 'cookie-parser';
import express from 'express';
import helmet from 'helmet';
import { rateLimit } from 'express-rate-limit';
const app = express();
app.disable('x-powered-by');
app.use(helmet());
app.use(express.json({ limit: '32kb' }));
app.use(cookieParser());
app.use('/auth/login', rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-8',
legacyHeaders: false,
}));
app.use('/auth', authRouter);
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ error: 'INTERNAL_SERVER_ERROR' });
});
운영 서버에서는 TLS를 Nginx, 로드 밸런서, 관리형 플랫폼 같은 신뢰 가능한 경계에서 종료할 수 있습니다. 어떤 구성이든 클라이언트가 인증서를 검증할 수 있어야 합니다.
2022년 원문처럼 Postman이나 HTTP client에서 SSL certificate verification을 끄는 것은 해결책이 아닙니다. 로컬 개발은 localhost HTTP를 사용하거나 로컬 CA로 신뢰할 수 있는 개발 인증서를 만드세요. 운영 환경에서는 공인 인증서를 사용하고 인증서 오류의 원인을 수정해야 합니다.
브라우저 쿠키 인증을 사용하면서 다른 origin의 프런트엔드를 허용한다면 CORS의 정확한 origin allowlist, credentials 설정, CSRF 방어를 함께 설계해야 합니다. Access-Control-Allow-Origin: *와 credential cookie는 함께 사용할 수 없습니다.
테스트해야 할 시나리오
| 시나리오 | 기대 결과 |
|---|---|
| 잘못된 비밀번호 | 401, 비밀번호나 사용자 존재 여부를 로그에 노출하지 않음 |
| 만료된 Access Token | 401 |
| 잘못된 issuer 또는 audience | 401 |
| 정상 Refresh Token | 새 Access Token과 새 Refresh Token 발급 |
| 이미 회전한 Refresh Token 재사용 | 401, 정책에 따라 token family 전체 폐기 |
| 로그아웃한 Refresh Token 사용 | 401 |
| 같은 Refresh Token 동시 요청 | 하나만 성공 |
| SQL injection 문자열 입력 | placeholder로 값 처리, 쿼리 구조가 바뀌지 않음 |
| 운영 환경의 HTTP 요청 | HTTPS로 전환하거나 거부 |
통합 테스트에서는 실제 테스트 DB를 사용해 트랜잭션과 동시 갱신을 확인하세요. JWT 단위 테스트만 통과해도 DB의 경합 조건까지 검증되는 것은 아닙니다.
구현 점검표
- 비밀번호를 Argon2id 또는 동등한 password hashing 방식으로 저장한다.
- Access Token의
exp,iss,aud, 허용 algorithm을 검증한다. - JWT payload에 비밀정보를 넣지 않는다.
- Refresh Token은 안전한 난수이며 DB에는 해시만 저장한다.
- Refresh Token 갱신을 트랜잭션과 행 잠금으로 처리한다.
- 로그인 endpoint에 rate limit을 적용한다.
- 모든 SQL 입력에 placeholder를 사용한다.
- Refresh Token 쿠키에
HttpOnly,Secure, 적절한SameSite와Path를 설정한다. - 인증서 검증을 끄지 않는다.
- secret과 DB 자격 증명을 저장소에 커밋하지 않는다.
JWT 로그인의 핵심은 토큰을 만드는 한 줄이 아닙니다. 자격 증명 저장, 토큰 수명, 폐기, 회전, 동시성, 쿠키, TLS가 하나의 경계를 이뤄야 합니다. 이 경계 중 하나라도 빠지면 “로그인이 된다”와 “로그인을 안전하게 운영한다” 사이에 큰 차이가 생깁니다.
댓글
댓글 쓰기