금요일

JWT 에러 완전 정복: TokenExpiredError, invalid signature 원인과 해결법

🔍 검색 키워드: JWT 에러, jwt expired 해결, invalid signature jwt, TokenExpiredError, JWT 트러블슈팅, jwt 토큰 에러, Spring JWT 에러, Node.js jwt 에러, jwt 인증 오류

JWT를 처음 붙이든, 실무에서 수년째 쓰든 어느 날 갑자기 TokenExpiredError 또는 invalid signature가 튀어나온다. 원인은 뻔한데 처음 보면 당황스럽다. 이 글에서 JWT 에러의 종류별 원인과 해결법을 레벨별로 정리한다.


JWT 에러 종류 한눈에 보기

에러 메시지원인긴급도
TokenExpiredError: jwt expired토큰 유효기간 만료보통 (정상 흐름)
JsonWebTokenError: invalid signature키 불일치 또는 토큰 변조높음
JsonWebTokenError: jwt malformed토큰 형식 자체가 잘못됨높음
NotBeforeError: jwt not activenbf 클레임 이전에 사용낮음
JsonWebTokenError: invalid algorithm알고리즘 불일치높음

LEVEL 1 초보자: 기본 개념부터

JWT는 세 파트로 구성된다: Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9    ← Header (알고리즘, 타입)
.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjox...  ← Payload (클레임: sub, exp, iat 등)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_...   ← Signature (서명)

Signature는 HMAC-SHA256(base64(header) + "." + base64(payload), secretKey)로 만든다.
토큰이 변조되면 서명 검증에서 실패한다. 이것이 JWT 보안의 핵심.

가장 흔한 실수 — 토큰을 Authorization 헤더에 제대로 안 보냄

# 잘못된 예
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

# 올바른 예
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

LEVEL 2 중급자: 에러별 원인과 해결

TokenExpiredError: jwt expired

토큰 발급 시 설정한 exp(만료시간)가 지났을 때 발생한다. 정상적인 흐름이다.

Node.js (jsonwebtoken)

const jwt = require('jsonwebtoken');
try {
  const decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
  if (err.name === 'TokenExpiredError') {
    return res.status(401).json({ code: 'TOKEN_EXPIRED' });
  }
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({ code: 'INVALID_TOKEN' });
  }
}

Java (Spring Boot + jjwt)

public Claims parseToken(String token) {
    try {
        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(token)
            .getBody();
    } catch (ExpiredJwtException e) {
        throw new CustomException(ErrorCode.TOKEN_EXPIRED);
    } catch (SignatureException e) {
        throw new CustomException(ErrorCode.INVALID_SIGNATURE);
    }
}

Python (PyJWT)

import jwt
try:
    payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
    raise HTTPException(status_code=401, detail="토큰이 만료되었습니다.")
except jwt.InvalidSignatureError:
    raise HTTPException(status_code=401, detail="유효하지 않은 토큰입니다.")

JsonWebTokenError: invalid signature

가장 많은 삽질을 유발하는 에러다. 원인은 크게 세 가지.

원인 1 — 서버 재시작 후 환경변수가 바뀜

echo $JWT_SECRET   # 양쪽 서버에서 값이 같은지 확인

원인 2 — HS256 vs RS256 알고리즘 불일치

// 발급 시 RS256 사용
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
// 검증 시 반드시 publicKey + 알고리즘 명시
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // ✅
HS256: 대칭키 (같은 secret으로 발급·검증)
RS256: 비대칭키 (privateKey로 발급, publicKey로 검증)

원인 3 — URL 전달 시 토큰이 잘림

const url = `/verify?token=${encodeURIComponent(token)}`;
const token = decodeURIComponent(req.query.token);

JsonWebTokenError: jwt malformed

const authHeader = req.headers.authorization; // "Bearer eyJ..."
const token = authHeader.split(' ')[1]; // Bearer 제거 후 verify
jwt.verify(token, secret);

LEVEL 3 고급자: 실무 패턴

Access Token + Refresh Token 구조

function issueTokens(userId) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  return { accessToken, refreshToken };
}

Spring Security + JWT 필터 체인

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String bearer = request.getHeader("Authorization");
        if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
            String token = bearer.substring(7);
            try {
                Claims claims = jwtProvider.parseToken(token);
                SecurityContextHolder.getContext().setAuthentication(
                    jwtProvider.getAuthentication(claims));
            } catch (ExpiredJwtException e) {
                response.setStatus(401);
                response.getWriter().write("{"code":"TOKEN_EXPIRED"}");
                return;
            }
        }
        filterChain.doFilter(request, response);
    }
}

상황별 체크리스트

상황확인 항목
invalid signature양쪽 환경의 JWT_SECRET 값이 동일한가?
invalid signature알고리즘이 HS256/RS256으로 일치하는가?
invalid signatureRS256이라면 검증 측에 publicKey를 쓰고 있는가?
jwt malformedBearer prefix를 제거하고 verify 하는가?
jwt malformedURL 전달 시 encodeURIComponent 처리 했는가?
토큰이 금방 만료됨expiresIn 단위: '1h'(1시간) vs 3600(초)
서버 재시작 후 전체 만료환경변수 기반 secret인가?

디버깅 도구

jwt.io — 토큰을 붙여넣으면 header, payload, signature를 즉시 디코딩해준다.

echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig" \
  | cut -d'.' -f2 | base64 --decode 2>/dev/null
🗂 정리

JWT 에러의 90%는 세 가지다: 키 불일치, 알고리즘 불일치, 토큰 만료.
invalid signature가 나오면 키와 알고리즘을 먼저 의심하고, 환경변수가 양쪽에서 같은지 확인하는 것이 제일 빠른 경로다.
TokenExpiredError는 에러가 아니라 정상 흐름이니 클라이언트 리프레시 로직을 갖춰두면 된다.

목요일

CORS 에러 완전 정복 — 원인부터 해결까지 (초보자~실무자)

개발하다 보면 누구나 한 번쯤은 마주치는 그 빨간 에러.

Access to XMLHttpRequest at 'https://api.example.com/data' from origin 'http://localhost:3000'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

처음 보면 황당하다. 코드는 분명히 맞는데, 브라우저가 요청을 막는다. 이 글에서는 CORS가 왜 생기는지, 어떻게 해결하는지를 레벨별로 정리한다.


1. CORS가 뭔데?

CORS (Cross-Origin Resource Sharing) — 직역하면 "교차 출처 리소스 공유".

브라우저는 기본적으로 Same-Origin Policy(동일 출처 정책)를 따른다. 즉, http://localhost:3000에서 실행 중인 페이지가 https://api.example.com에 요청을 보내면, 출처(origin)가 다르기 때문에 브라우저가 막는다.

여기서 Origin = 프로토콜 + 도메인 + 포트 세 가지가 모두 같아야 동일 출처다.

비교동일 출처?
http://example.com vs https://example.com❌ 프로토콜 다름
http://example.com vs http://api.example.com❌ 도메인 다름
http://example.com:3000 vs http://example.com:8080❌ 포트 다름
http://example.com/a vs http://example.com/b✅ 동일 출처

2. 핵심 원리 — 브라우저가 하는 일

중요한 포인트: CORS는 서버가 막는 게 아니라 브라우저가 막는다.

서버는 이미 응답을 줬다. 하지만 브라우저가 그 응답에 Access-Control-Allow-Origin 헤더가 없으면 자바스크립트 코드에 넘겨주지 않고 차단한다. curl이나 Postman으로는 에러 없이 잘 되는 이유가 이것이다.

Preflight 요청

GET, POST 외의 메서드거나, 커스텀 헤더가 있거나, Content-Type이 application/json이면 브라우저는 실제 요청 전에 OPTIONS 메서드로 사전 확인 요청(Preflight)을 먼저 보낸다.


3. 해결 방법

🟢 초보자 — 로컬 개발 환경

프론트엔드 개발 서버 프록시 설정 (가장 흔한 방법)

CRA (package.json):

{
  "proxy": "https://api.example.com"
}

Vite (vite.config.ts):

export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
}

🟡 중급자 — 서버 사이드 CORS 헤더 설정

Node.js (Express):

const cors = require('cors');

app.use(cors({
  origin: ['https://myapp.com', 'https://www.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true
}));

Spring Boot (Java):

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://myapp.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

Nginx:

location /api/ {
    add_header 'Access-Control-Allow-Origin' 'https://myapp.com';
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
    add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Length' 0;
        return 204;
    }
    proxy_pass http://backend;
}

🔴 고급 — 자주 빠지는 함정들

① credentials: true인데 * 와일드카드 쓰면 안 된다

쿠키나 Authorization 헤더 포함 요청은 Access-Control-Allow-Origin: *이어도 브라우저가 차단한다. 정확한 출처를 명시해야 한다.

# 틀린 예
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

# 맞는 예
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true

② Preflight OPTIONS 요청 처리를 빠뜨리는 경우

수동으로 헤더를 추가할 때 OPTIONS 처리를 생략하는 실수가 많다. 204를 반환해야 본 요청이 들어온다.

③ 여러 서버에서 헤더가 중복으로 붙는 경우

Access-Control-Allow-Origin이 두 개 붙으면 브라우저가 에러를 낸다. 한 레이어에서만 처리해야 한다.

④ 동적 출처 허용

const allowedOrigins = ['https://myapp.com', 'https://app2.com'];
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }
  next();
});

4. 상황별 체크리스트

상황해결책
로컬 개발 중 API 서버 연결 안 됨프론트 dev 서버 프록시 설정
Postman은 되는데 브라우저만 에러서버에 CORS 헤더 추가
OPTIONS 요청이 405 에러Preflight 핸들러 추가
쿠키/JWT 포함 요청이 차단됨* 대신 정확한 origin + credentials: true
배포 후 갑자기 안 됨Nginx/로드밸런서 헤더 중복 확인
서드파티 API (서버 못 건드림)내 서버를 프록시로 우회

마무리

CORS는 처음엔 황당하지만 원리를 알면 단순하다. 브라우저가 보안 정책으로 막는 것이고, 서버가 헤더로 허용해줘야 풀린다. Postman은 되는데 브라우저에서만 안 된다면 거의 무조건 CORS 문제다.

운영 환경에서는 절대 * 와일드카드 쓰지 말고, 꼭 필요한 출처만 명시하는 것이 기본이다.

금요일

테크 스타트업의 조직문화


테크팀의 조직문화


  • 항상 개방적인 마인드를 갖고 협상의 여지를 두고 소통합니다.
  • 자율에는 책임이 따르므로 자율과 책임을 분리해서 일하지 않습니다.
  • 업무의 우선순위는 항상 바뀔 수 있음을 인지합니다.
  • 본인 스스로가 병목현상의 원인 제공자가 되는 걸 부끄러워 하고 책임감 있게 맡은 일을 해냅니다.
  • 회의가 필요한 경우 주최자가 사전에 안건을 참석자들에게 메일로 공유하고 구글 캘린더로 미팅 시간을 사전 조율하고 확정 하는 과정을 거칩니다.
  • 회의는 1시간 이내로 진행하며, 회의 종료 후에는 주최자가 회의록을 작성하고 회의 참석자들에게 메일로 공유합니다.
  • 온라인으로 업무 소통을 할 경우 언어적 표현으로 오해가 생길 소지를 만들지 않습니다.
  • 상호간의 업무 플로우를 존중하고자 즉시 소통이 필요한 경우 slack을 사용하고 그 외 jira를 기본으로 일합니다.