레이블이 인증/보안인 게시물을 표시합니다. 모든 게시물 표시
레이블이 인증/보안인 게시물을 표시합니다. 모든 게시물 표시

월요일

Python SSL 인증서 에러 완전 정복: CERTIFICATE_VERIFY_FAILED 원인과 해결법

🔍 검색 키워드: CERTIFICATE_VERIFY_FAILED · Python SSL 에러 · ssl.SSLCertVerificationError · Python requests SSL 오류 · macOS Python SSL · Python urllib SSL 인증서 오류

Python으로 외부 API 호출하거나 크롤링하다 보면 이 에러 한 번쯤은 만난다.

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: unable to get local issuer certificate (_ssl.c:1129)

이거 처음 보면 당황스럽다. 에러 메시지만 봐서는 뭐가 문제인지 감도 안 온다. 원인부터 단계별 해결까지 정리한다.


왜 이 에러가 나는가

Python이 HTTPS 요청을 보낼 때 서버의 SSL 인증서를 검증한다. 이 과정에서 신뢰할 수 있는 루트 인증서(CA) 목록을 참조하는데, 여기에 서버 인증서 체인이 없으면 에러가 난다.

주요 원인은 크게 세 가지다.

  • Python 자체 CA 번들이 오래됨 — macOS에서 Python 3.6+ 공식 배포판은 시스템 인증서를 쓰지 않고 번들로 따로 관리하는데, 이게 업데이트 안 되면 발생한다.
  • 회사 네트워크의 프록시/방화벽 — 기업 환경에서 HTTPS 트래픽을 자체 인증서로 중간에서 가로채는(MITM) 경우, Python이 그 인증서를 모른다.
  • 자체 서명(self-signed) 인증서를 가진 서버 — 내부 개발 서버나 테스트 환경에서 자주 발생.

상황별 체크리스트

상황원인권장 해결책
macOS + Python 공식 설치번들 CA 미업데이트Install Certificates.command 실행
회사 내부 네트워크기업 프록시 인증서기업 CA 인증서 추가
내부 개발 서버self-signed 인증서verify=인증서경로 지정
Docker/CI 환경CA 번들 미포함certifi 패키지 + 환경변수 설정
갑자기 발생 (예전엔 됐는데)Python 또는 OS 업그레이드certifi 재설치

레벨별 해결 방법

초보자 — 일단 돌아가게 만들기 (비추천)

개발 중에 빠르게 확인만 할 때 쓰는 방법이다. 절대 프로덕션에 쓰면 안 된다.

import requests

# SSL 검증 비활성화 — 개발용으로만!
response = requests.get("https://example.com", verify=False)

중급자 — certifi로 CA 번들 업데이트

가장 권장하는 방법이다. certifi는 Mozilla가 관리하는 신뢰할 수 있는 CA 목록을 Python에서 쓸 수 있게 패키징한 라이브러리다.

pip install --upgrade certifi
import requests
import certifi

response = requests.get("https://example.com", verify=certifi.where())
print(response.status_code)

환경변수로 전역 적용 (추천)

# Linux/macOS
export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
export REQUESTS_CA_BUNDLE=$(python -c "import certifi; print(certifi.where())")

macOS 전용 — Install Certificates.command 실행

# 해당 버전의 Install Certificates 스크립트 실행 (버전에 맞게 경로 수정)
open /Applications/Python\ 3.12/Install\ Certificates.command

고급자 — 기업 CA 인증서 추가

import requests

# 단일 파일
response = requests.get("https://internal.company.com", verify="/path/to/company-ca.crt")
import certifi

with open("/path/to/company-ca.crt", "r") as f:
    company_cert = f.read()

with open(certifi.where(), "a") as bundle:
    bundle.write("\n" + company_cert)

Docker/CI 환경

FROM python:3.12-slim

RUN pip install certifi

ENV SSL_CERT_FILE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
ENV REQUESTS_CA_BUNDLE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
# GitHub Actions
- name: Fix SSL certificates
  run: |
    pip install --upgrade certifi
    echo "SSL_CERT_FILE=$(python -c 'import certifi; print(certifi.where())')" >> $GITHUB_ENV
    echo "REQUESTS_CA_BUNDLE=$(python -c 'import certifi; print(certifi.where())')" >> $GITHUB_ENV

urllib / aiohttp 사용자 참고

import ssl, urllib.request, certifi

ctx = ssl.create_default_context(cafile=certifi.where())
req = urllib.request.Request("https://example.com")
with urllib.request.urlopen(req, context=ctx) as response:
    data = response.read()
import aiohttp, ssl, certifi

async def fetch(url):
    ssl_ctx = ssl.create_default_context(cafile=certifi.where())
    connector = aiohttp.TCPConnector(ssl=ssl_ctx)
    async with aiohttp.ClientSession(connector=connector) as session:
        async with session.get(url) as response:
            return await response.text()

절대 하면 안 되는 것

⚠️ 프로덕션에서 이런 코드 보이면 반드시 수정해야 한다.
# ❌ 절대 하지 말 것
import ssl
ssl._create_default_https_context = ssl._create_unverified_context

# ❌ 이것도 마찬가지
requests.get(url, verify=False)

# ❌ 환경변수로 전체 비활성화
# PYTHONHTTPSVERIFY=0

SSL 검증을 끄는 순간 중간자 공격(MITM)에 완전히 노출된다. 개발 편의를 위해 껐다가 프로덕션에 그대로 배포되는 사고가 실제로 발생한다.


정리

우선순위해결책상황
1순위Install Certificates.commandmacOS + 공식 Python 설치
2순위pip install --upgrade certifi + 환경변수대부분의 환경
3순위기업 CA 인증서 추가회사 내부 네트워크
4순위verify=False로컬 개발 일시적 확인 (프로덕션 절대 불가)
💡 실무에서 이 에러를 자주 만나는 패턴은 Python 버전 업그레이드 직후, 또는 새 팀원이 회사 환경 세팅할 때다. 팀 위키에 이 내용 정리해두면 반복 질문 많이 줄어든다.

금요일

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 문제다.

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