JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증
이 글의 핵심
JWT는 서버에 상태를 두지 않아 편하지만, 한번 발급한 토큰을 강제로 만료시키기 어렵고 저장 위치에 따라 XSS나 CSRF 위험이 달라집니다. Access Token을 짧게 두고 Refresh Token으로 갱신하는 구조, OAuth를 붙이며 실제로 부딪힌 지점을 짚어 배포 환경에서 자주 받는 질문에 답할 수 있게 합니다.
이 글의 핵심
JWT로 상태 없이 검증하는 흐름과, OAuth로 외부 로그인을 붙인 뒤 우리 서비스 토큰을 발급하는 흐름을 예제로 정리합니다. 이론 체크리스트보다는 배포 과정에서 자주 묻는 지점 위주로 다룹니다.
사전 지식 (초보자를 위한 기초)
인증(Authentication) vs 인가(Authorization)
인증 (Authentication): “당신은 누구인가?”
로그인 흐름 예:
- 사용자가 아이디·비밀번호를 보낸다
- 서버가 검증해 본인이면 이후 요청에 쓸 수단(세션·토큰 등)을 준다
인가 (Authorization): “무엇을 할 수 있는가?”
권한 확인 예:
- 삭제 요청이 오면 역할(관리자·일반)을 본다
- 정책에 없으면 거절한다
인증은 “누구인지”, 인가는 “무엇을 할 수 있는지”입니다. 둘을 섞어 말하면 설계가 흐려지기 쉽습니다.
HTTP는 Stateless
Stateless는 서버가 이전 요청을 기억하지 않는다는 뜻입니다.
문제 상황:
요청 1: POST /login (로그인 성공!)
요청 2: GET /profile (누구인지 모름)
요청 3: GET /posts (누구인지 모름)
HTTP는 각 요청을 독립적으로 처리하므로
서버는 "이 사용자가 로그인했다"는 것을 기억 못함!
해결 방법:
- 세션: 서버에 로그인 정보 저장
- 토큰: 클라이언트가 신분증(토큰) 들고 다님
쿠키(Cookie)란?
쿠키는 브라우저가 저장했다가 같은 사이트로 보내는 요청에 실어 보내는 작은 데이터입니다.
// 서버가 쿠키 설정
res.cookie('sessionId', 'abc123', {
httpOnly: true, // JavaScript로 접근 불가 (보안)
secure: true, // HTTPS에서만 전송
maxAge: 3600000 // 1시간
});
// 이후 모든 요청에 자동으로 쿠키 포함
// Cookie: sessionId=abc123
인증이 필요한 이유와 방식의 변화
인증의 필요성
인증이 없다면:
- 누구나 다른 사람의 정보 조회 가능
- 누구나 게시글 삭제 가능
- 보안 사고 발생!
인증이 있다면:
- 로그인한 사용자만 접근
- 본인 데이터만 조회/수정
- 안전한 서비스 운영
인증 방식의 진화
1세대: 기본 인증 (Basic Auth)
- ID/PW를 매 요청마다 전송
- 보안 취약 (Base64 인코딩만)
2세대: 세션 기반 인증
- 서버에 로그인 정보 저장
- 세션 ID를 쿠키로 전송
- 서버 메모리 사용
3세대: 토큰 기반 인증 (JWT)
- 서버가 상태 저장 안함
- 토큰에 모든 정보 포함
- 확장성 좋음
4세대: OAuth 2.0
- 소셜 로그인 (구글, 카카오)
- 제3자 인증 위임
세션 인증과 토큰 인증
세션 기반 인증
┌─────────┐ ┌─────────┐
│ Client │ │ Server │
└────┬────┘ └────┬────┘
│ │
│ 1. POST /login │
│ (ID: alice, PW: 1234) │
├─────────────────────────────>│
│ │ 2. 비밀번호 확인
│ │ 3. 세션 생성 (메모리 저장)
│ │ sessions['abc123'] = { userId: 1 }
│ 4. Set-Cookie: sessionId=abc123
│<─────────────────────────────┤
│ │
│ 5. GET /profile │
│ Cookie: sessionId=abc123 │
├─────────────────────────────>│
│ │ 6. 세션 확인
│ │ sessions['abc123'] → userId: 1
│ 7. { name: "Alice", ... } │
│<─────────────────────────────┤
장점:
- 서버에서 세션 제어 가능 (강제 로그아웃)
- 세션 데이터 서버에서 관리 단점:
- 서버 메모리 사용 (사용자 많으면 부담)
- 수평 확장 어려움 (세션 공유 필요)
- CORS 문제 (쿠키 전송 제한)
토큰 기반 인증 (JWT)
┌─────────┐ ┌─────────┐
│ Client │ │ Server │
└────┬────┘ └────┬────┘
│ │
│ 1. POST /login │
│ (ID: alice, PW: 1234) │
├─────────────────────────────>│
│ │ 2. 비밀번호 확인
│ │ 3. JWT 생성 (서명)
│ │ token = sign({ userId: 1 })
│ 4. { token: "eyJhbG..." } │
│<─────────────────────────────┤
│ 5. localStorage에 저장 │
│ │
│ 6. GET /profile │
│ Authorization: Bearer eyJhbG...
├─────────────────────────────>│
│ │ 7. JWT 검증 (서명 확인)
│ │ verify(token) → userId: 1
│ 8. { name: "Alice", ... } │
│<─────────────────────────────┤
장점:
- 서버가 상태 저장 안함 (Stateless)
- 수평 확장 쉬움
- 모바일 앱 친화적 단점:
- 토큰 크기 큼 (매 요청마다 전송)
- 강제 로그아웃 어려움
- 토큰 탈취 시 위험
OAuth와 맞물리는 JWT·Refresh Token
이 글의 중심은 OAuth 2.0 기초부터 이어지는 OAuth 2.0이므로, JWT 자체는 OAuth와 맞물리는 지점만 요약합니다. JWT 구조와 서명 생성·검증 코드, Access Token + Refresh Token 패턴, Next.js 구현, 저장 위치와 흔한 실수는 JWT 인증 가이드에서 자세히 다룹니다. Express에서 bcrypt·세션·Helmet까지 포함한 시리즈 실습은 Node.js 인증 #7에 있습니다.
JWT는 서명된 클레임 묶음입니다. 실무에서 말하는 JWT 문자열은 RFC 7519 클레임을 RFC 7515 JWS로 만든 컴팩트 서명(header.payload.signature)입니다. 페이로드는 Base64url 인코딩일 뿐 암호화가 아니므로 누구나 읽을 수 있고, decode만으로는 위조 여부를 알 수 없습니다. 반드시 기대하는 alg와 키로 서명을 재검증한 뒤 exp/nbf를 확인해야 합니다. 검증 라이브러리에 허용 알고리즘을 명시하지 않으면 alg를 바꿔치기하는 공격(예: none, RS256 공개키를 HS256 비밀키로 쓰게 만드는 혼동)에 노출될 수 있습니다.
OAuth와 만나는 지점은 키와 클레임 검증입니다. HS256은 발급자와 검증자가 같은 비밀키를 공유하므로 우리 서비스 내부 토큰에 적합하고, RS256/ES256은 발급자만 개인키를 갖고 검증자는 공개키(JWKS)만 받으므로 Google·카카오 같은 외부 IdP의 id_token 검증에 쓰입니다. 외부 토큰을 받을 때 iss(발급자)와 aud(대상)를 검증하지 않으면 다른 서비스용으로 발급된 토큰을 우리 서비스가 잘못 수용할 수 있습니다. Google OAuth 구현 이후의 흐름은 “IdP 토큰을 검증한 뒤 우리 서비스용 JWT를 새로 발급한다”는 전제 위에서 동작합니다.
Refresh Token은 짧은 Access Token의 불편을 메웁니다. Access Token을 15분 정도로 짧게 두면 탈취 피해는 줄지만 사용자가 자주 로그아웃됩니다. 수명이 긴 Refresh Token을 서버 저장소(DB·Redis)에 기록해 두고 재발급에만 쓰면, 강제 로그아웃이 가능해지고 Access Token은 무상태로 유지됩니다. 재발급할 때마다 Refresh Token도 새로 바꾸는 회전(rotation)과, 이미 쓴 Refresh Token이 다시 오면 그 사용자의 토큰 계열 전체를 폐기하는 재사용 탐지를 함께 두는 것이 현재의 권장 방식입니다. 토큰별 고유 ID인 jti가 이 추적의 열쇠입니다. 구현 코드는 JWT 인증 가이드의 Refresh Token 절을 참고하세요.
브라우저에서는 저장 위치가 위협 모델을 바꿉니다. localStorage에 둔 토큰은 XSS 한 번으로 스크립트가 읽어 갈 수 있고, httpOnly 쿠키에 둔 토큰은 스크립트로 읽을 수 없는 대신 쿠키가 자동 전송되므로 CSRF 대책(SameSite, CSRF 토큰)이 필요합니다. JWT 보안과 XSS·CSRF 방어 절이 이 선택을 전제로 합니다.
OAuth 구현하면서 겪은 것들
OAuth를 붙일 때 문서대로만 하면 되는 줄 알았는데, 첫 프로덕션 배포에서는 거의 항상 리다이렉트 URI 한 글자나 빠뜨린 state 때문에 밤을 새우게 됩니다. 저는 로컬에서는 되는데 스테이징에서만 redirect_uri_mismatch가 나던 날, 콘솔에 등록한 URL과 실제 콜백이 미세하게 다른 부분을 찾느라 로그를 뒤졌습니다. 그래서 결론 하나를 분명히 해 둡니다. PKCE는 선택이 아니라 필수입니다. SPA·모바일·공개 클라이언트라면 더더욱 그렇습니다. 인가 코드만 믿고 가면 가로채기 시나리오에 너무 쉽게 노출됩니다. 서버 쪽 기밀 클라이언트라는 이유로 “우리는 시크릿이 있으니까” 하며 PKCE를 건너뛰는 팀도 봤지만, 저는 이제 새 코드 플로우에는 무조건 PKCE부터 깔고 시작합니다. OAuth 2.1의 흐름도 그쪽으로 기울어 있고, 구현해 두면 디버깅할 때도 마음이 편합니다. 아래는 그때 정리해 둔 플로우·용어입니다. 표로 암기하기보다 한 번 브라우저 네트워크 탭에서 authorize → 콜백 → token 순서를 직접 확인해 보면 훨씬 빨리 몸에 익습니다.
OAuth 2.0 기초
OAuth 2.0이란?
OAuth 2.0은 제3자 앱이 사용자 동의 하에 리소스에 접근하도록 권한을 위임하는 프로토콜입니다. 비유:
호텔 발렛 파킹:
- 당신: 리소스 소유자 (차 주인)
- 발렛 직원: 제3자 애플리케이션
- 차: 보호된 리소스
- 발렛 키: Access Token (트렁크 못 열게 제한)
OAuth도 마찬가지:
- 구글 계정 (리소스)
- 우리 앱 (제3자)
- 제한된 권한 (이메일만 조회)
OAuth 2.0 플로우
┌─────────┐ ┌─────────┐ ┌─────────┐
│ User │ │ Our │ │ Google │
│ │ │ App │ │ (OAuth)│
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ 1. "구글로 로그인" 클릭 │ │
├───────────────────────────>│ │
│ │ 2. 구글 로그인 페이지로 리다이렉트
│ ├───────────────────────────>│
│ 3. 구글 로그인 화면 │ │
│<────────────────────────────────────────────────────────┤
│ │ │
│ 4. ID/PW 입력, 권한 승인 │ │
├────────────────────────────────────────────────────────>│
│ │ │ 5. 인증 코드 생성
│ 6. 인증 코드와 함께 리다이렉트 │
│<────────────────────────────────────────────────────────┤
│ │ │
│ 7. 인증 코드 전달 │ │
├───────────────────────────>│ │
│ │ 8. 인증 코드로 Access Token 요청
│ ├───────────────────────────>│
│ │ 9. Access Token 발급 │
│ │<───────────────────────────┤
│ │ 10. 사용자 정보 요청 │
│ ├───────────────────────────>│
│ │ 11. 사용자 정보 (이메일 등) │
│ │<───────────────────────────┤
│ 12. 로그인 완료 │ │
│<───────────────────────────┤ │
OAuth 2.0 주요 용어
1) Resource Owner (리소스 소유자)
- 사용자 (당신) 2) Client (클라이언트)
- 우리 애플리케이션 3) Authorization Server (인증 서버)
- 구글, 카카오 등의 OAuth 서버 4) Resource Server (리소스 서버)
- 사용자 정보를 제공하는 서버 5) Authorization Code (인증 코드)
- 임시 코드, Access Token으로 교환 6) Access Token (액세스 토큰)
- 리소스 접근 권한
인가 코드(Authorization Code) 플로우 — 내부 동작
위 다이어그램은 사실상 인가 코드 부여(Authorization Code Grant) 흐름입니다. OAuth 2.0에서 “로그인 버튼 → 구글 → 다시 우리 앱”으로 돌아오는 구간을 프론트 채널(브라우저 리다이렉트) 이라 부르고, 인증 코드를 액세스 토큰으로 바꾸는 HTTP POST 는 백 채널(서버 간) 에서 이루어지는 경우가 많습니다. 이 분리가 핵심입니다.
왜 리다이렉트 URL에 바로 액세스 토큰을 실어 보내지 않는가?
역사적·호환성 이유도 있지만, 실무적으로는 토큰이 URL·브라우저 기록·Referer 에 노출될 위험을 줄이기 위해 짧게 살아 있는 일회성 코드만 프론트에 넘기고, 클라이언트 비밀(client secret) 을 둘 수 있는 백엔드에서 토큰 엔드포인트로 교환하는 패턴이 널리 쓰입니다. 공개 클라이언트(모바일·순수 SPA)는 시크릿을 안전하게 숨길 수 없으므로 PKCE로 같은 위협을 완화합니다(아래 참고).
전형적인 단계 (RFC 6749 기준 개념):
- 인가 요청: 클라이언트가 사용자 브라우저를 인가 엔드포인트로 보냅니다.
response_type=code로 코드를 요청합니다. - 사용자 인증·동의: 인증 서버가 로그인·동의 화면을 보여 줍니다.
- 리다이렉트: 승인되면
redirect_uri로 돌아오며 쿼리에code가 붙습니다. CSRF 방지용state를 보냈다면 그대로 돌려받아 검증합니다. - 토큰 요청: 클라이언트(보통 백엔드)가 토큰 엔드포인트에
grant_type=authorization_code로code,redirect_uri, 클라이언트 식별 정보 등을 보내 액세스 토큰(경우에 따라 리프레시 토큰 포함)을 받습니다. - 리소스 접근: 액세스 토큰으로 리소스 서버(또는 사용자 정보 엔드포인트)를 호출합니다.
클라이언트 유형도 함께 이해해야 합니다. 기밀(confidential) 클라이언트는 서버에서 동작해 client secret 을 숨길 수 있고, 공개(public) 클라이언트는 시크릿 없이 동작하므로 PKCE·리다이렉트 URI 엄격 일치 같은 보완이 필수에 가깝습니다.
PKCE(Proof Key for Code Exchange)
PKCE는 인가 코드가 가로채였을 때 공격자가 그 코드로 토큰을 받아 가는 상황을 어렵게 만듭니다. 클라이언트가 코드 검증기(code_verifier) 를 만들고, 그로부터 계산한 코드 챌린지(code_challenge) 만 인가 요청에 넣습니다. 토큰 교환 시에는 원본 code_verifier 를 토큰 엔드포인트에 보내 검증합니다.
흐름 요약:
- code_verifier: 길고 예측하기 어려운 난수 문자열(일반적으로 43–128자 범위의 URL-safe 문자).
- code_challenge:
BASE64URL(SHA256(code_verifier))(S256 방법이 사실상 표준). - 인가 요청 시
code_challenge와code_challenge_method=S256를 전달합니다. - 토큰 요청 시
code_verifier를 함께 보내면, 인증 서버가 해시가 동일한지 확인합니다.
브라우저 기반 SPA·모바일 앱처럼 client secret 을 둘 수 없는 환경에서 인가 코드 플로우 + PKCE 는 “권장” 수준이 아니라, 저는 그냥 필수라고 봅니다. 백엔드만 있고 시크릿이 있더라도, 새로 짜는 인가 코드 플로우에는 PKCE를 기본으로 넣는 쪽이 이후 감사·보안 질문에도 답이 됩니다. OAuth 2.1 은 새 구현에 PKCE를 사실상 기대하는 방향으로 정리되어 있습니다.
토큰 엔드포인트(Token Endpoint)
토큰 엔드포인트는 액세스 토큰(및 선택적으로 리프레시 토큰)을 발급·갱신하는 HTTPS 전용 API 입니다. 인가 코드를 받은 뒤의 첫 교환뿐 아니라, grant_type=refresh_token 으로 액세스 토큰 갱신도 여기서 처리합니다.
인가 코드 교환 시(개념적 파라미터):
grant_type:authorization_codecode: 리다이렉트로 받은 일회성 코드redirect_uri: 인가 요청 때 사용한 것과 동일해야 하는 경우가 많습니다client_id: 등록된 클라이언트 ID- 기밀 클라이언트:
client_secret또는 HTTP Basic 으로 클라이언트 인증 - PKCE 사용 시:
code_verifier
응답은 보통 JSON 이며 access_token, token_type(보통 Bearer), expires_in 을 포함합니다. OpenID Connect 를 쓰면 id_token 이 추가되기도 합니다. 에러는 error, error_description 으로 돌아오므로, 프로덕션에서는 이를 로깅·알림에 포함하되 시크릿·코드 값은 마스킹합니다.
스코프(Scope)와 동의(Consent)
스코프는 “이 토큰으로 무엇을 할 수 있는지”를 문자열로 표현한 것입니다. 공백으로 구분된 목록으로 전달되는 경우가 많고, 제공자마다 정의한 권한 집합(예: 프로필 읽기, 이메일, 캘린더)에 대응합니다.
동의 화면은 사용자가 해당 스코프에 실제로 동의했는지를 확보하는 단계입니다. 최초 로그인 때 많은 권한을 한꺼번에 요구하면 이탈이 늘 수 있으므로, 필요한 최소 스코프만 요청하고 점진적 인가(incremental authorization) 로 나중에 추가 권한을 요청하는 전략이 권장됩니다. 이미 부여된 스코프는 세션에 따라 동의 화면이 생략되기도 합니다.
애플리케이션을 설계할 때 “우리 서비스의 RBAC” 과 “IdP 가 부여한 OAuth 스코프” 를 혼동하지 않는 것이 중요합니다. 소셜 로그인 후에는 보통 우리 쪽 사용자 레코드와 우리 API용 JWT/세션을 별도로 발급해 권한을 관리합니다.
실서비스에서 반복되는 OAuth 패턴
실서비스에서 반복되는 패턴을 정리하면 다음과 같습니다.
- 리다이렉트 URI 엄격 일치: 스킴·호스트·포트·경로·슬래시까지 등록값과 완전히 동일하게 맞춥니다.
redirect_uri_mismatch는 대부분 여기서 발생합니다. state+ PKCE:state로 CSRF 를 막고, 공개 클라이언트는 PKCE 로 코드 탈취를 완화합니다.- 토큰 저장소: 브라우저에서는 가능하면 HttpOnly·Secure·SameSite 쿠키 등으로 XSS 노출면을 줄이며, SPA 는 BFF(백엔드 포 프론트) 패턴으로 토큰을 서버 측에 두는 선택도 흔합니다.
- 짧은 액세스 토큰 + 리프레시·로테이션: 탈취 윈도우를 줄이고, 리프레시 토큰 재사용 탐지 등으로 계정 탈취에 대응합니다.
- OIDC 사용 시:
id_token은 JWT 이므로 서명 검증에 JWKS 엔드포인트를 사용하고,nonce로 리플레이를 완화합니다. - 관측 가능성: 인가 실패·토큰 엔드포인트 오류율·지연을 대시보드에 올려 고객 지원 문의와의 상관관계를 분석합니다.
이 절의 내용은 Passport 같은 라이브러리가 내부에서 대신 처리해 주는 부분이 많지만, 장애 분석·보안 감사·새 플랫폼(모바일·데스크톱 딥링크) 연동 시에는 위 개념을 직접 알아야 원인을 빠르게 좁힐 수 있습니다.
Google OAuth를 Passport.js로 구현하기
Google OAuth 설정
1) Google Cloud Console 설정
1. https://console.cloud.google.com 접속
2. 프로젝트 생성
3. "API 및 서비스" → "사용자 인증 정보"
4. "OAuth 2.0 클라이언트 ID" 생성
5. 승인된 리디렉션 URI 추가:
http://localhost:3000/auth/google/callback
6. Client ID와 Client Secret 복사
Passport.js를 사용한 구현
npm install passport passport-google-oauth20 express-session
server.js
const express = require('express');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const session = require('express-session');
const app = express();
// 세션 설정
app.use(session({
secret: 'session-secret',
resave: false,
saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());
// Google OAuth 전략 설정
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/google/callback'
},
(accessToken, refreshToken, profile, done) => {
// 사용자 정보 저장 (DB)
const user = {
id: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
picture: profile.photos[0].value
};
return done(null, user);
}
));
// 세션 직렬화
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
// DB에서 사용자 조회
const user = { id, email: '[email protected]' };
done(null, user);
});
// 구글 로그인 시작
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
// 구글 로그인 콜백
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
// 로그인 성공
res.redirect('/dashboard');
}
);
// 로그아웃
app.get('/logout', (req, res) => {
req.logout((err) => {
if (err) return next(err);
res.redirect('/');
});
});
// 보호된 라우트
app.get('/dashboard', (req, res) => {
if (!req.isAuthenticated()) {
return res.redirect('/login');
}
res.json({ user: req.user });
});
app.listen(3000);
프론트엔드 구현
<!DOCTYPE html>
<html>
<head>
<title>OAuth Login</title>
</head>
<body>
<h1>로그인</h1>
<!-- 구글 로그인 버튼 -->
<a href="/auth/google">
<button>구글로 로그인</button>
</a>
<!-- 카카오 로그인 버튼 -->
<a href="/auth/kakao">
<button>카카오로 로그인</button>
</a>
</body>
</html>
카카오 OAuth 구현
카카오 개발자 설정
1. https://developers.kakao.com 접속
2. 애플리케이션 추가
3. "플랫폼" → "Web" 추가
4. Redirect URI 설정:
http://localhost:3000/auth/kakao/callback
5. "동의 항목" 설정 (이메일, 프로필)
6. REST API 키 복사
Passport-Kakao 구현
npm install passport-kakao
const KakaoStrategy = require('passport-kakao').Strategy;
passport.use(new KakaoStrategy({
clientID: process.env.KAKAO_CLIENT_ID,
callbackURL: 'http://localhost:3000/auth/kakao/callback'
},
(accessToken, refreshToken, profile, done) => {
const user = {
id: profile.id,
email: profile._json.kakao_account.email,
name: profile.displayName,
picture: profile._json.properties.profile_image
};
return done(null, user);
}
));
// 카카오 로그인 라우트
app.get('/auth/kakao',
passport.authenticate('kakao')
);
app.get('/auth/kakao/callback',
passport.authenticate('kakao', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
JWT 보안과 XSS·CSRF 방어
JWT 보안
1) Secret Key 관리
// ❌ 나쁜 예: 코드에 하드코딩
const SECRET = 'my-secret-key';
// ✅ 좋은 예: 환경 변수 사용
const SECRET = process.env.JWT_SECRET;
// .env 파일
JWT_SECRET=randomly-generated-long-secret-key-256-bits
2) HTTPS 사용 필수
// ❌ HTTP에서 토큰 전송 (탈취 위험)
http://example.com/api?token=eyJhbG...
// ✅ HTTPS에서만 토큰 전송
https://example.com/api
Authorization: Bearer eyJhbG...
3) 짧은 만료 시간
// ❌ 너무 긴 만료 시간
jwt.sign(payload, secret, { expiresIn: '30d' });
// ✅ 짧은 만료 시간 + Refresh Token
jwt.sign(payload, secret, { expiresIn: '15m' });
4) 민감한 정보 저장 금지
// ❌ 비밀번호 저장
const token = jwt.sign({ userId: 1, password: '1234' }, secret);
// ✅ 최소한의 정보만
const token = jwt.sign({ userId: 1, role: 'user' }, secret);
XSS 방어
XSS (Cross-Site Scripting): 악성 스크립트 삽입 공격
// ❌ localStorage에 토큰 저장 (XSS 취약)
localStorage.setItem('token', token);
// 악성 스크립트: localStorage.getItem('token')
// ✅ HttpOnly 쿠키에 저장
res.cookie('token', token, {
httpOnly: true, // JavaScript로 접근 불가
secure: true, // HTTPS에서만 전송
sameSite: 'strict' // CSRF 방어
});
CSRF 방어
CSRF (Cross-Site Request Forgery): 사용자 모르게 요청 전송
// CSRF 토큰 생성
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.use(csrfProtection);
app.get('/form', (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/process', csrfProtection, (req, res) => {
res.send('처리 완료');
});
<!-- 폼에 CSRF 토큰 포함 -->
<form method="POST" action="/process">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit">제출</button>
</form>
역할 기반 접근 제어와 토큰 블랙리스트
역할 기반 접근 제어 (RBAC)
// 역할 확인 미들웨어
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: '권한이 없습니다' });
}
next();
};
}
// 관리자만 접근 가능
app.delete('/users/:id',
authenticateToken,
requireRole('admin'),
(req, res) => {
// 사용자 삭제
res.json({ message: '삭제 완료' });
}
);
// 다중 역할 지원
function requireAnyRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: '권한이 없습니다' });
}
next();
};
}
app.get('/admin/dashboard',
authenticateToken,
requireAnyRole('admin', 'moderator'),
(req, res) => {
res.json({ message: '관리자 대시보드' });
}
);
토큰 블랙리스트
const redis = require('redis');
const client = redis.createClient();
// 로그아웃 시 토큰 블랙리스트에 추가
app.post('/logout', authenticateToken, async (req, res) => {
const token = req.headers['authorization'].split(' ')[1];
// JWT에서 만료 시간 추출
const decoded = jwt.decode(token);
const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
// Redis에 블랙리스트 저장
await client.setex(`blacklist:${token}`, expiresIn, 'true');
res.json({ message: '로그아웃 성공' });
});
// 인증 미들웨어에서 블랙리스트 확인
async function authenticateToken(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '토큰이 없습니다' });
}
// 블랙리스트 확인
const isBlacklisted = await client.get(`blacklist:${token}`);
if (isBlacklisted) {
return res.status(401).json({ error: '로그아웃된 토큰입니다' });
}
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) return res.status(403).json({ error: '유효하지 않은 토큰' });
req.user = user;
next();
});
}
세션·JWT·OAuth, 표 대신 상황별로 고르기
표로 비교하려고 하면 오히려 막막해집니다. 제 기준은 이렇습니다. 전통 웹(서버 렌더) 이고 서버가 세션을 쥐고 있어도 된다면 세션이 직관적이고 강제 로그아웃도 쉽게 풀립니다. API·모바일·수평 확장이 핵심이라면 JWT 쪽이 덜 번거롭지만, 대신 토큰 보관·만료·탈취 대응이 숙제로 남습니다. 구글/카카오 로그인을 붙이거나 다른 서비스의 리소스를 사용자 대신 쓴다면 그것이 곧 OAuth 영역이며, 이때는 “우리 서비스 권한”과 IdP 스코프를 섞지 말고 로그인 후 우리 쪽 토큰/세션을 따로 두는 쪽이 정신 건강에 이롭습니다. 복잡도는 OAuth가 가장 높지만, 그만큼 운영에서 받는 질문도 리다이렉트·state·PKCE 같은 실전 질문입니다. 암기용 표는 없앴으니, 위의 OAuth 구현하면서 겪은 것들 단락과 함께 보면 됩니다.
jwt malformed·expired, CORS, OAuth 콜백 에러
1) “jwt malformed” 에러
// 원인: 잘못된 토큰 형식
// 해결: Bearer 접두사 제거
const token = authHeader.split(' ')[1]; // "Bearer TOKEN"
2) “jwt expired” 에러
// 원인: 토큰 만료
// 해결: Refresh Token으로 재발급
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'expired', message: '토큰이 만료되었습니다' });
}
3) CORS 에러
// 해결: CORS 설정
// 변수 선언 및 초기화
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3001',
credentials: true // 쿠키 전송 허용
}));
4) OAuth 콜백 에러
원인: Redirect URI 불일치
해결:
1. OAuth 제공자 설정 확인
2. 정확한 URI 입력 (http vs https, 포트 번호)
3. 로컬 테스트: http://localhost:3000/auth/google/callback
배포 전 보안 점검과 Rate Limiting
보안 체크리스트
- HTTPS 사용
- Secret Key 환경 변수로 관리
- 짧은 Access Token 만료 시간 (예: 15분)
- Refresh Token DB 저장
- 비밀번호 해싱 (bcrypt, scrypt)
- Rate Limiting (무차별 대입 공격 방어)
- CORS 설정
- HttpOnly, Secure 쿠키
- XSS 방어 (입력 검증, sanitization)
- CSRF 토큰
Rate Limiting 구현
const rateLimit = require('express-rate-limit');
// 로그인 엔드포인트 제한
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 최대 5회 시도
message: '너무 많은 로그인 시도입니다. 15분 후 다시 시도하세요.'
});
app.post('/login', loginLimiter, async (req, res) => {
// 로그인 로직
});
Express로 만든 인증 시스템 전체 코드
프로젝트 구조
auth-system/
├── src/
│ ├── config/
│ │ └── passport.js
│ ├── middleware/
│ │ ├── auth.js
│ │ └── rateLimiter.js
│ ├── models/
│ │ └── User.js
│ ├── routes/
│ │ ├── auth.js
│ │ └── users.js
│ └── server.js
├── .env
└── package.json
완전한 구현
src/middleware/auth.js
// 변수 선언 및 초기화
const jwt = require('jsonwebtoken');
exports.authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '인증이 필요합니다' });
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'expired' });
}
return res.status(403).json({ error: '유효하지 않은 토큰' });
}
req.user = user;
next();
});
};
exports.requireRole = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: '권한이 없습니다' });
}
next();
};
};
src/routes/auth.js
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const router = express.Router();
// 회원가입
router.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
// 이메일 중복 확인
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ error: '이미 존재하는 이메일입니다' });
}
// 비밀번호 해싱
const hashedPassword = await bcrypt.hash(password, 10);
// 사용자 생성
const user = await User.create({
email,
password: hashedPassword,
name
});
res.status(201).json({
message: '회원가입 성공',
userId: user.id
});
} catch (error) {
res.status(500).json({ error: '서버 오류' });
}
});
// 로그인
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// 사용자 찾기
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 틀렸습니다' });
}
// 비밀번호 확인
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 틀렸습니다' });
}
// Access Token 생성
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
// Refresh Token 생성
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// Refresh Token DB에 저장
await user.update({ refreshToken });
res.json({ accessToken, refreshToken });
} catch (error) {
res.status(500).json({ error: '서버 오류' });
}
});
// 토큰 재발급
router.post('/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh Token이 없습니다' });
}
// JWT 검증
const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
// DB에서 Refresh Token 확인
const user = await User.findOne({
id: decoded.userId,
refreshToken
});
if (!user) {
return res.status(403).json({ error: '유효하지 않은 Refresh Token' });
}
// 새로운 Access Token 발급
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Refresh Token이 만료되었습니다' });
}
res.status(403).json({ error: '유효하지 않은 Refresh Token' });
}
});
// 로그아웃
router.post('/logout', async (req, res) => {
try {
const { refreshToken } = req.body;
// DB에서 Refresh Token 삭제
await User.updateOne(
{ refreshToken },
{ $unset: { refreshToken: 1 } }
);
res.json({ message: '로그아웃 성공' });
} catch (error) {
res.status(500).json({ error: '서버 오류' });
}
});
module.exports = router;
JWT 발급·검증 테스트
JWT 테스트
const request = require('supertest');
const app = require('./server');
describe('Authentication', () => {
let accessToken;
it('회원가입 성공', async () => {
const res = await request(app)
.post('/register')
.send({
email: '[email protected]',
password: '1234',
name: 'Test User'
});
expect(res.status).toBe(201);
});
it('로그인 성공', async () => {
const res = await request(app)
.post('/login')
.send({
email: '[email protected]',
password: '1234'
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('accessToken');
accessToken = res.body.accessToken;
});
it('보호된 라우트 접근', async () => {
const res = await request(app)
.get('/profile')
.set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toBe(200);
});
it('토큰 없이 접근 시 401', async () => {
const res = await request(app)
.get('/profile');
expect(res.status).toBe(401);
});
});
FAQ
Q1. JWT를 어디에 저장해야 하나요?
- localStorage: 편리하지만 XSS 취약
- HttpOnly Cookie: 더 안전하지만 CSRF 방어 필요
- 추천: HttpOnly Cookie + CSRF 토큰 Q2. Access Token 만료 시간은 얼마가 적당한가요?
- 일반적: 15분~1시간
- 민감한 서비스: 5~15분
- 덜 민감한 서비스: 1~24시간 Q3. Refresh Token은 어디에 저장하나요?
- DB에 저장 (강제 로그아웃 가능)
- Redis에 저장 (빠른 조회) Q4. OAuth 2.0과 JWT를 함께 쓰나요? 흔한 패턴입니다. IdP에서 인가 코드로 사용자 정보를 받은 뒤, 우리 서비스의 세션·JWT를 발급해 씁니다.
app.get('/auth/google/callback',
passport.authenticate('google'),
(req, res) => {
// OAuth 로그인 성공 후 JWT 발급
const token = jwt.sign({ userId: req.user.id }, SECRET);
res.json({ token });
}
);
같이 보면 좋은 글
- Node.js 인증과 보안 | JWT, bcrypt, 세션, OAuth
- JWT 인증 구현
- OAuth 2.0 & JWT authentication — token login, refresh flows,