JWT(JSON Web Token)는 서버 세션 없이 인증 상태를 유지하는 방법이다. 구조와 흐름은 언어에 상관없이 동일하고, 구현 방식만 조금씩 다르다. Node.js를 메인으로, Python(FastAPI)과 Go도 함께 정리한다.
JWT 구조
JWT는 .으로 구분된 세 부분으로 이루어진다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header (알고리즘, 토큰 타입)
.eyJ1c2VySWQiOiIxMjMiLCJpYXQiOjE2OTk... ← Payload (실제 데이터)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV... ← Signature (변조 검증)Header와 Payload는 Base64로 인코딩되어 있어 누구나 디코딩할 수 있다. 민감한 데이터(비밀번호, 카드번호 등)는 절대 Payload에 넣으면 안 된다. Signature는 서버의 secret key로 서명되어 있어서 변조 여부를 검증할 수 있다.
전체 흐름
1. 클라이언트 → 서버: 아이디 + 비밀번호 전송
2. 서버: 비밀번호 검증 → 일치하면 JWT 발급
3. 서버 → 클라이언트: JWT 반환
4. 클라이언트: JWT를 localStorage 또는 메모리에 저장
5. 이후 요청: Authorization: Bearer <token> 헤더에 첨부
6. 서버: JWT 검증 → 유효하면 요청 처리Node.js (Express)
패키지 설치
npm install jsonwebtoken bcryptjs
토큰 발급 — 로그인
// routes/auth.js
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'
import User from '../models/User.js'
export async function login(req, res) {
const { email, password } = req.body
// 1. 유저 조회
const user = await User.findOne({ email })
if (!user) {
return res.status(401).json({ message: '이메일 또는 비밀번호가 틀렸습니다' })
}
// 2. 비밀번호 검증
const isMatch = await bcrypt.compare(password, user.password)
if (!isMatch) {
return res.status(401).json({ message: '이메일 또는 비밀번호가 틀렸습니다' })
}
// 3. JWT 발급
const token = jwt.sign(
{ userId: user._id, email: user.email }, // Payload
process.env.JWT_SECRET, // Secret key
{ expiresIn: '7d' } // 만료 시간
)
res.json({ token })
}
회원가입 시 비밀번호 해싱
import bcrypt from 'bcryptjs'
export async function register(req, res) {
const { email, password } = req.body
// 비밀번호 해싱 (salt rounds: 10)
const hashedPassword = await bcrypt.hash(password, 10)
const user = await User.create({
email,
password: hashedPassword, // 원본 비밀번호는 절대 저장하지 않는다
})
res.status(201).json({ message: '회원가입 완료' })
}
인증 미들웨어
// middleware/auth.js
import jwt from 'jsonwebtoken'
export function authenticate(req, res, next) {
const authHeader = req.headers.authorization
// "Bearer " 형식 확인
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: '토큰이 없습니다' })
}
const token = authHeader.split(' ')[1]
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
req.user = decoded // 이후 핸들러에서 req.user.userId로 접근
next()
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ message: '토큰이 만료됐습니다' })
}
return res.status(401).json({ message: '유효하지 않은 토큰입니다' })
}
}
라우트에 미들웨어 적용
// routes/plans.js
import { authenticate } from '../middleware/auth.js'
router.get('/plans', authenticate, async (req, res) => {
// req.user.userId 사용 가능
const plans = await Plan.find({ userId: req.user.userId })
res.json(plans)
})
Python (FastAPI)
패키지 설치
pip install python-jose[cryptography] passlib[bcrypt] fastapi
설정 및 유틸
# auth/utils.py
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7일
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode["exp"] = expire
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
토큰 발급 — 로그인 엔드포인트
# routes/auth.py
from fastapi import APIRouter, HTTPException, Depends
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
router = APIRouter()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
@router.post("/auth/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
user = await get_user_by_email(form_data.username)
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다")
token = create_access_token({"sub": str(user.id), "email": user.email})
return {"access_token": token, "token_type": "bearer"}
인증 의존성 (미들웨어 대신 Depends 사용)
# auth/dependencies.py
from fastapi import Depends, HTTPException
from jose import JWTError
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = decode_token(token)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
except JWTError:
raise HTTPException(status_code=401, detail="토큰 검증 실패")
user = await get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=401, detail="유저를 찾을 수 없습니다")
return user
# 라우트에서 사용
@router.get("/plans")
async def get_plans(current_user = Depends(get_current_user)):
return await Plan.find({"user_id": current_user.id})
Go (net/http + golang-jwt)
패키지 설치
go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto/bcrypt
토큰 발급
// auth/jwt.go
package auth
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
var secretKey = []byte("your-secret-key")
type Claims struct {
UserID string `json:"userId"`
Email string `json:"email"`
jwt.RegisteredClaims
}
func GenerateToken(userID, email string) (string, error) {
claims := &Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secretKey)
}
func ValidateToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
return secretKey, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
인증 미들웨어
// middleware/auth.go
package middleware
import (
"net/http"
"strings"
"myapp/auth"
)
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "토큰이 없습니다", http.StatusUnauthorized)
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := auth.ValidateToken(tokenStr)
if err != nil {
http.Error(w, "유효하지 않은 토큰", http.StatusUnauthorized)
return
}
// Context에 유저 정보 저장
ctx := context.WithValue(r.Context(), "userId", claims.UserID)
next(w, r.WithContext(ctx))
}
}
언어별 비교
| 항목 | Node.js (Express) | Python (FastAPI) | Go |
|---|---|---|---|
| JWT 라이브러리 | jsonwebtoken |
python-jose |
golang-jwt |
| bcrypt 라이브러리 | bcryptjs |
passlib[bcrypt] |
golang.org/x/crypto/bcrypt |
| 미들웨어 방식 | middleware function |
Depends() 의존성 주입 |
HandlerFunc 래핑 |
| 에러 처리 | try/catch + 상태코드 | HTTPException |
error 반환값 |
| 토큰 파싱 | jwt.verify() |
jwt.decode() |
jwt.ParseWithClaims() |
세 언어 모두 흐름은 동일하다. 발급 → 헤더 첨부 → 검증 → 페이로드 추출. 라이브러리 이름과 에러 처리 방식만 다르다.
프론트엔드 (React) — 토큰 저장 및 첨부
로그인 후 토큰 저장
async function login(email, password) {
const res = await axios.post('/api/auth/login', { email, password })
localStorage.setItem('token', res.data.token)
}
Axios 인터셉터로 자동 첨부
// api/axios.js
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 401 응답 시 자동 로그아웃
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
인터셉터를 한 번만 설정해두면 모든 API 요청에 토큰이 자동으로 붙는다. 각 컴포넌트에서 토큰을 신경 쓸 필요가 없다.
보안 주의사항
localStorage vs HttpOnly Cookie
localStorage는 구현이 간단하지만 XSS 공격에 취약하다. 공격자가 스크립트를 심으면 토큰을 탈취할 수 있다. 보안이 중요한 서비스라면 HttpOnly Cookie를 쓰는 게 좋다. HttpOnly Cookie는 JavaScript에서 접근 자체가 불가능하다.
// 서버에서 HttpOnly Cookie로 토큰 전달
res.cookie('token', token, {
httpOnly: true, // JS 접근 불가
secure: true, // HTTPS에서만 전송
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7일
})
토큰 만료 시간
| 용도 | 권장 만료 시간 |
|---|---|
| 일반 웹앱 | 1일 ~ 7일 |
| 금융/보안 민감 | 15분 ~ 1시간 |
| 모바일 앱 | 30일 (Refresh Token 병행) |
짧게 설정할수록 보안은 높아지지만 사용자가 자주 재로그인해야 한다. Refresh Token을 함께 쓰면 보안과 편의성을 둘 다 잡을 수 있다.
절대 하지 말 것
// ❌ Payload에 민감 정보 넣기 — 누구나 디코딩 가능
jwt.sign({ password: '1234', cardNumber: '1234-...' }, secret)
// ❌ secret key를 코드에 하드코딩
const token = jwt.sign(payload, 'my-secret-key')
// ✅ 환경 변수로 관리
const token = jwt.sign(payload, process.env.JWT_SECRET)
정리
| 단계 | Node.js | Python | Go |
|---|---|---|---|
| 비밀번호 해싱 | bcrypt.hash() |
pwd_context.hash() |
bcrypt.GenerateFromPassword() |
| 토큰 발급 | jwt.sign() |
jwt.encode() |
token.SignedString() |
| 토큰 검증 | jwt.verify() |
jwt.decode() |
jwt.ParseWithClaims() |
| 미들웨어 | (req, res, next) |
Depends() |
HandlerFunc 래핑 |
구조를 한 번 이해하면 언어가 바뀌어도 라이브러리 이름만 달라질 뿐 흐름은 그대로다.
'개발 팁' 카테고리의 다른 글
| 풀스택 앱 무료 배포 완전 가이드 — Vercel + Railway + MongoDB Atlas (0) | 2026.02.25 |
|---|---|
| npm 패키지 배포 전 체크리스트 — npm pack 활용법 (0) | 2026.02.25 |
| React + Vite 프로젝트 초기 세팅 — 매번 하는 것들 정리 (0) | 2026.02.25 |
| VSCode 필수 확장 프로그램 추천 - 2026년 실사용 기준 (0) | 2026.01.26 |