Published on

EKS ALB Ingress 401 반복 - OIDC·JWT·헤더 점검

Authors

서버가 살아 있고(200을 내야 할 것 같은데) ALB Ingress에서만 401이 반복되는 상황은 생각보다 흔합니다. 특히 AWS Load Balancer Controller(ALB Ingress) + OIDC(Authenticate) + 애플리케이션 JWT 검증이 섞이면, 인증이 두 번 적용되거나(중복 인증), 토큰이 헤더로 전달되지 않거나(헤더 유실), 토큰의 audience/issuer가 불일치하는 식으로 401 루프가 발생합니다.

이 글은 “EKS에서 ALB Ingress 401 반복”을 OIDC·JWT·헤더 관점에서 빠르게 좁혀가는 체크리스트로 구성했습니다. (ALB 5xx 계열 문제와 달리 401은 대부분 설정/정책/헤더 계층에서 발생합니다.)

> 참고로 5xx(502/504)와 섞여 보이면 먼저 네트워크/헬스체크를 분리해서 보세요: EKS ALB Ingress 504(5xx) 간헐 발생 원인·해결, EKS에서 ALB Ingress 502 Bad Gateway 원인 9가지

1) 먼저 “누가 401을 내는지”부터 분리

401의 주체가 다르면 접근법이 완전히 달라집니다.

1-1. ALB가 401을 내는 경우(Authenticate 단계)

ALB OIDC 인증(Authenticate OIDC/Cognito)이 실패하면 ALB가 직접 401/302를 반환합니다.

  • 브라우저에서는 로그인 페이지로 302 리다이렉트가 반복되거나
  • API 호출에서는 401과 함께 www-authenticate 또는 OIDC 관련 쿠키/리다이렉트 힌트가 보일 수 있습니다.

1-2. 애플리케이션이 401을 내는 경우(JWT 검증 단계)

ALB는 요청을 정상적으로 백엔드로 전달했지만, 앱이 JWT를 검증하다가 401을 내는 케이스입니다.

  • 응답 헤더에 앱 서버/프레임워크 특징이 보이거나
  • ALB Access Log에서 target_status_code=401 형태로 확인됩니다.

1-3. 빠른 판별 방법: ALB 액세스 로그

ALB 액세스 로그를 켜고(또는 이미 켜져 있다면) 아래 필드를 봅니다.

  • elb_status_code = ALB가 클라이언트에 준 코드
  • target_status_code = 타겟(Pod/Service)이 준 코드

elb_status_code=401이고 target_status_code=-ALB 인증 단계에서 막힌 것입니다. elb_status_code=401이면서 target_status_code=401이면 앱도 401을 내고 있을 가능성이 큽니다(혹은 ALB가 401을 그대로 전달).

2) ALB Ingress OIDC 설정에서 가장 흔한 함정

AWS Load Balancer Controller에서 OIDC 인증은 보통 Ingress annotation으로 구성합니다.

2-1. auth-type/auth-idp-oidc JSON 오타 및 필수값 누락

대표적으로 JSON 문자열 escaping 문제, issuer/authorizationEndpoint/tokenEndpoint/userInfoEndpoint 누락, secretName 불일치가 401 원인이 됩니다.

아래는 예시입니다(값은 환경에 맞게 변경).

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'

    # OIDC 인증
    alb.ingress.kubernetes.io/auth-type: oidc
    alb.ingress.kubernetes.io/auth-on-unauthenticated-request: authenticate
    alb.ingress.kubernetes.io/auth-scope: openid profile email
    alb.ingress.kubernetes.io/auth-session-timeout: '3600'
    alb.ingress.kubernetes.io/auth-session-cookie: AWSELBAuthSessionCookie
    alb.ingress.kubernetes.io/auth-idp-oidc: >-
      {"issuer":"https://idp.example.com","authorizationEndpoint":"https://idp.example.com/oauth2/authorize","tokenEndpoint":"https://idp.example.com/oauth2/token","userInfoEndpoint":"https://idp.example.com/oauth2/userinfo","secretName":"oidc-client-secret"}

spec:
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: api-svc
            port:
              number: 80

점검 포인트:

  • secretName이 존재하는지
  • secret에 clientID, clientSecret 키가 기대 형식인지(컨트롤러 버전에 따라 키 이름이 다를 수 있어 문서/예제 확인 필요)
  • issuer정확히 일치하는지(끝 슬래시 유무 포함)

2-2. 콜백 URL/리다이렉트 URI 불일치

OIDC 공급자(IdP) 콘솔에서 등록한 redirect URI가 ALB의 콜백 경로와 다르면 인증 후 다시 401/302 루프가 납니다.

  • 일반적으로 ALB는 https://<host>/oauth2/idpresponse 경로를 사용합니다(구성에 따라 다를 수 있음).
  • X-Forwarded-Proto/HTTPS 종단이 꼬이면 IdP에 http://로 등록되어 실패하는 경우도 있습니다.

2-3. 쿠키 도메인/SameSite 문제(특히 크로스 도메인)

프론트(app.example.com)와 API(api.example.com)가 분리되어 있고 브라우저 기반 호출이라면, OIDC 세션 쿠키가 의도대로 전송되지 않아 401이 반복될 수 있습니다.

  • SameSite 정책 때문에 third-party로 간주되면 쿠키가 빠짐
  • 도메인/Path가 맞지 않으면 쿠키가 저장/전송되지 않음

이 경우는 브라우저 개발자 도구에서 Set-Cookie와 실제 요청의 Cookie 헤더를 비교하면 바로 드러납니다.

3) “ALB OIDC + 앱 JWT”를 같이 쓸 때 생기는 401 루프

가장 흔한 구조는 다음 중 하나입니다.

  1. ALB에서 OIDC 인증을 하고, 앱은 “인증된 사용자 헤더”를 신뢰
  2. 앱이 Bearer JWT를 검증하고, ALB는 단순 L7 라우팅
  3. 둘 다 켜져 있는데(중복), 서로 기대하는 토큰/헤더가 달라서 401 반복

3-1. 중복 인증: ALB는 OIDC 세션 쿠키, 앱은 Authorization: Bearer 기대

ALB OIDC 인증은 기본적으로 브라우저 세션(쿠키) 기반으로 동작합니다. 반면 많은 API 서버는 Authorization: Bearer <jwt>를 기대합니다.

  • 클라이언트가 Bearer 토큰을 보내지 않는데 앱이 Bearer를 강제하면 → 앱 401
  • 클라이언트가 Bearer 토큰을 보내지만 ALB가 먼저 OIDC를 강제하면 → ALB 401/302

해결 방향(택1):

  • API는 Bearer로만 가고 싶다 → Ingress에서 auth-type 제거(또는 경로별로 분리)
  • ALB OIDC를 쓰고 앱은 헤더 기반으로 신뢰 → 앱의 JWT 검증을 끄거나, ALB가 주는 클레임 헤더를 검증하도록 변경

3-2. ALB가 주는 OIDC 관련 헤더를 앱이 못 읽는 경우

ALB OIDC 인증을 통과하면 요청에 사용자 정보 헤더가 추가될 수 있습니다(구성/버전에 따라 다름). 앱이 이를 기대하는데 실제로는 전달이 안 되는 경우가 있습니다.

대표 원인:

  • Ingress/TargetGroup의 헤더 허용/차단(프록시/미들웨어에서 drop)
  • 앱 서버(예: Spring, Express, Envoy)에서 underscores/대문자 헤더 처리가 달라 누락
  • CORS preflight(OPTIONS)는 인증 제외가 필요하지만 인증이 걸려 401

4) JWT 검증 관점 체크리스트(issuer/audience/alg/kid)

앱이 401을 반환한다면 JWT 검증 실패가 핵심입니다.

4-1. issuer(iss) 불일치

iss는 문자열 완전 일치가 기본입니다.

  • https://idp.example.com vs https://idp.example.com/ 차이
  • Cognito의 경우 리전/유저풀 경로가 정확해야 함

4-2. audience(aud) 불일치

SPA/모바일/서버 간에 client_id가 다르면 aud가 달라집니다.

  • 프론트에서 받은 토큰은 aud=frontend-client
  • 백엔드가 기대하는 aud=api-client

이 경우 “토큰은 유효한데 우리 서비스용 토큰이 아님”으로 401이 납니다.

4-3. alg, kid, JWKS 회전

IdP가 키를 회전하면 kid가 바뀌고, 앱의 JWKS 캐시가 오래되면 검증 실패가 발생합니다.

  • 증상: 특정 시점부터 401 급증, 시간이 지나면 자연 회복/반복
  • 해결: JWKS 캐시 TTL 단축, 실패 시 재조회 로직 추가

아래는 Python(PyJWT)에서 JWKS를 사용해 검증하는 최소 예시입니다.

import time
import requests
import jwt
from jwt import PyJWKClient

ISSUER = "https://idp.example.com"
JWKS_URL = f"{ISSUER}/.well-known/jwks.json"
AUDIENCE = "api-client-id"

_jwk_client = PyJWKClient(JWKS_URL)

def verify(token: str) -> dict:
    signing_key = _jwk_client.get_signing_key_from_jwt(token).key
    return jwt.decode(
        token,
        signing_key,
        algorithms=["RS256"],
        audience=AUDIENCE,
        issuer=ISSUER,
        options={"require": ["exp", "iat", "iss"]},
    )

if __name__ == "__main__":
    t = "<paste jwt>"
    print(verify(t))

4-4. clock skew(서버 시간 오차)

exp, nbf, iat 검증에서 서버 시간이 어긋나면 간헐 401이 납니다.

  • 노드 NTP 동기화
  • 허용 skew(예: 60~120초) 적용

5) 헤더 전달/변조 문제: ALB → TargetGroup → Pod

401이 “토큰이 없어서”라면 대개 헤더가 중간에서 사라졌습니다.

5-1. 먼저 Pod에서 실제로 어떤 헤더를 받는지 덤프

가장 빠른 방법은 디버그용 echo 서버를 붙여보는 것입니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: header-echo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: header-echo
  template:
    metadata:
      labels:
        app: header-echo
    spec:
      containers:
      - name: echo
        image: ealen/echo-server:0.9.2
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: header-echo
spec:
  selector:
    app: header-echo
  ports:
  - port: 80
    targetPort: 80

Ingress를 이 서비스로 잠깐 붙인 뒤, 클라이언트가 보내는 Authorization/쿠키가 Pod까지 오는지 확인합니다.

5-2. curl로 “ALB 앞단”과 “Pod 직통” 비교

# 1) ALB 도메인으로 호출
curl -vk https://api.example.com/me \
  -H 'Authorization: Bearer <JWT>'

# 2) 포트포워딩으로 Pod/Service 직통
kubectl port-forward svc/api-svc 18080:80
curl -vk http://127.0.0.1:18080/me \
  -H 'Authorization: Bearer <JWT>'
  • Pod 직통은 200인데 ALB에서는 401 → ALB/OIDC/헤더 전달 계층 문제
  • 둘 다 401 → 앱 JWT 검증/설정 문제

5-3. OPTIONS 프리플라이트에 인증이 걸려 401

브라우저에서 CORS 요청이면 먼저 OPTIONS가 날아갑니다.

  • OPTIONS에 Authorization이 없는데 앱/ALB가 인증을 강제하면 401
  • 해결: OPTIONS 경로/메서드는 인증 제외 또는 CORS 미들웨어에서 먼저 처리

ALB Ingress에서 경로를 분리하거나(예: /api/*만 인증), 앱에서 OPTIONS를 먼저 허용하는 방식이 일반적입니다.

6) AWS Load Balancer Controller/Ingress 리소스 점검 명령

컨트롤러가 실제로 어떤 규칙/액션을 만들었는지 확인해야 “내가 의도한 OIDC 액션이 적용됐는지”를 알 수 있습니다.

# Ingress annotation/이벤트 확인
kubectl describe ingress api

# 컨트롤러 로그에서 auth 관련 에러 탐색
kubectl -n kube-system logs deploy/aws-load-balancer-controller \
  | egrep -i 'oidc|authenticate|unauth|error|denied'

또한 AWS CLI로 리스너 규칙을 직접 확인하면, Ingress annotation이 어떤 Listener Rule로 변환됐는지 볼 수 있습니다.

aws elbv2 describe-load-balancers --names <your-alb-name>
aws elbv2 describe-listeners --load-balancer-arn <alb-arn>
aws elbv2 describe-rules --listener-arn <listener-arn>

여기서 Rule action에 authenticate-oidc가 붙어 있는지, 우선순위/조건(host/path)이 의도대로인지 확인합니다.

7) 실전에서 가장 많이 맞닥뜨린 401 원인 TOP 7

현장에서 재현 빈도가 높은 순서로 정리하면 다음과 같습니다.

  1. OIDC redirect URI 불일치로 로그인 후 다시 401/302 루프
  2. ALB OIDC와 앱 Bearer JWT를 동시에 강제(중복 인증)
  3. audience 불일치(프론트 토큰을 백엔드에서 검증)
  4. issuer 문자열 불일치(슬래시/경로 차이)
  5. JWKS kid 회전 + 캐시로 특정 시점부터 401 급증
  6. CORS preflight(OPTIONS) 401
  7. 쿠키 SameSite/도메인 문제로 세션 쿠키 미전송

8) 권장 아키텍처 패턴: “인증 책임”을 한 군데로 모으기

401 반복은 대부분 책임 분리가 애매할 때 생깁니다. 아래 중 하나로 단순화하면 운영이 쉬워집니다.

패턴 A: ALB에서 OIDC 종료, 앱은 신뢰 헤더 기반

  • 장점: 앱이 OIDC/JWKS를 몰라도 됨
  • 단점: 헤더 신뢰 경계(내부망/ALB 뒤) 설계 필요

패턴 B: 앱에서 JWT 검증, ALB는 라우팅만

  • 장점: API/모바일/서버 간 통일된 Bearer 인증
  • 단점: JWKS 캐시/회전/스큐 등 앱에서 구현 필요

둘을 섞고 싶다면 “경로 기반 분리”가 안전합니다.

  • /web/*는 ALB OIDC
  • /api/*는 Bearer JWT

9) 마무리: 10분 내 진단 루틴

마지막으로, 401 반복을 빠르게 끝내는 루틴을 요약합니다.

  1. ALB 액세스 로그로 elb_status_code vs target_status_code 확인(주체 분리)
  2. curl -v로 리다이렉트/Set-Cookie/WWW-Authenticate 확인
  3. Pod 직통(port-forward)과 ALB 경유를 비교해 헤더 유실 여부 확인
  4. JWT라면 iss/aud/exp/kid를 로컬에서 검증(샘플 코드 활용)
  5. OIDC라면 redirect URI, issuer, secretName, listener rule을 AWS CLI로 재확인

401은 “서버가 죽어서”가 아니라 “서버가 의도적으로 거부해서” 생깁니다. 따라서 누가 거부했는지를 먼저 확정하고, 그 계층의 입력값(토큰/헤더/쿠키/리다이렉트)을 역추적하면 대부분 짧은 시간 안에 해결됩니다.

추가로 ALB 레이어에서의 다른 장애 패턴(502/504, idle timeout, health check 등)까지 함께 점검이 필요하다면 아래 글도 같이 보면 원인 분리가 빨라집니다: AWS ALB 502·504 난사 - 원인별 해결 체크리스트