본문 바로가기
Q&A해결2026년 8월 29일2분 읽기

JWT 로그인이 로컬에선 멀쩡한데 배포 서버에선 간헐적으로 invalid signature가 떠요

j
jwt_soyeon
조회 156댓글 4

운영자님 안녕하세요. 인증 쪽에서 재현이 안 되는 버그 때문에 스트레스 받고 있습니다.

로그인하면 HS256으로 JWT를 발급하고, 이후 요청마다 검증하는 구조입니다. 로컬에서는 100% 정상인데, 쿠버네티스에 replica 3개로 올린 배포 환경에서만 가끔 이런 에러가 납니다.

JsonWebTokenError: invalid signature
    at verify (node_modules/jsonwebtoken/verify.js:...)

토큰 발급 코드입니다.

// 앱 부팅 시 시크릿 생성
const SECRET = crypto.randomBytes(32).toString('hex');

function sign(user) {
  return jwt.sign({ sub: user.id }, SECRET, { expiresIn: '1h' });
}
function verify(token) {
  return jwt.verify(token, SECRET);
}

같은 토큰인데 어떤 요청은 통과하고 어떤 요청은 invalid signature가 납니다. 만료 시간도 넉넉한데 왜 이럴까요?

댓글 4

김김영삼2개월 전

안녕하세요. 코드만 봐도 원인이 명확합니다. 시크릿을 앱 부팅 시점에 crypto.randomBytes로 매번 새로 생성하고 있는 게 문제입니다.

replica가 3개면 파드마다 서로 다른 SECRET을 갖게 됩니다. 로드밸런서가 요청을 라운드로빈으로 뿌리니까:

  • 파드 A가 발급한 토큰이 → 검증하러 파드 B로 가면 → 시크릿이 달라서 invalid signature
  • 우연히 같은 파드(A)로 가면 → 통과

딱 이래서 간헐적으로 실패하는 겁니다. 로컬은 인스턴스가 하나라 시크릿이 하나뿐이니 항상 성공했던 거고요. 참고로 파드가 재시작돼도 시크릿이 바뀌기 때문에, 무중단 배포 도중에도 기존 발급 토큰이 전부 무효화됩니다.

해결은 모든 인스턴스가 동일한 시크릿을 공유하게 하는 겁니다. 코드에서 랜덤 생성하지 말고 환경변수로 주입하세요.

const SECRET = process.env.JWT_SECRET;
if (!SECRET) throw new Error('JWT_SECRET is required');

쿠버네티스라면 시크릿 값을 Secret 리소스에 넣고 env로 마운트하면 됩니다.

kubectl create secret generic jwt --from-literal=JWT_SECRET=$(openssl rand -hex 32)

이렇게 3개 파드가 같은 값을 바라보면 어느 파드에서 발급/검증하든 서명이 일치합니다.

jjwt_soyeon(guest)2개월 전
아 그거였군요. 파드마다 시크릿이 달라서 로드밸런싱 걸릴 때만 터진 거였네요. env로 빼니까 에러가 싹 사라졌습니다.추가로 하나만 더 여쭤봐도 될까요? 지금은 액세스 토큰만 쓰는데 만료를 1시간으로 잡으니 사용자가 자꾸 튕깁니다. 리프레시 토큰을 도입하려는데, 이걸 localStorage에 저장하는 게 나을까요 아니면 다른 방법이 있을까요?
김김영삼2개월 전

리프레시 토큰은 localStorage에 두지 않는 걸 권장합니다. localStorage는 자바스크립트로 언제든 읽을 수 있어서 XSS 한 방이면 토큰이 그대로 탈취됩니다. 특히 장기 유효한 리프레시 토큰이 털리면 피해가 훨씬 큽니다.

권장 구성은 이렇습니다.

  • 액세스 토큰: 짧게(15~30분), 메모리에만 보관. 페이지 새로고침되면 사라지는 게 정상이고, 리프레시로 다시 받으면 됩니다.
  • 리프레시 토큰: HttpOnly, Secure, SameSite=Strict(또는 Lax) 쿠키로 발급. HttpOnly라 JS가 못 읽으니 XSS로 못 빼갑니다.
res.cookie('refresh', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 1000 * 60 * 60 * 24 * 14, // 14일
path: '/auth/refresh'
});

두 가지만 더 챙기시면 좋습니다.

  • 리프레시 토큰 회전(rotation): 리프레시할 때마다 새 리프레시 토큰을 발급하고 이전 것은 무효화. 탈취된 토큰 재사용을 감지할 수 있습니다.
  • 리프레시 토큰은 서버(DB/Redis)에 저장해 두고 로그아웃/탈취 시 강제 무효화가 가능하게 하세요. 액세스 토큰은 stateless라 못 막지만, 만료가 짧으니 리프레시만 끊으면 곧 세션이 죽습니다.

쿠키 방식이면 CSRF는 신경 써야 하는데, SameSite=Strict에 리프레시 엔드포인트 경로를 path로 좁혀두면 대부분 막힙니다.

jjwt_soyeon(guest)2개월 전
메모리 + HttpOnly 쿠키 조합, 그리고 회전까지 개념이 완전히 잡혔습니다. 그동안 막연하게 localStorage에 다 넣던 게 얼마나 위험했는지 이제 알겠네요. 상세하게 답변 주셔서 정말 감사합니다 🙏
Ctrl+Enter로 등록