Published on

OAuth 로그인 후 401? JWT aud/iss 불일치 점검

Authors

OAuth 로그인까지는 잘 되는데, 막상 백엔드 API를 호출하면 401 Unauthorized가 떨어지는 케이스가 자주 있습니다. 특히 프런트에서 액세스 토큰을 정상적으로 받았고(응답에 access_token이 있고), 토큰을 Authorization: Bearer ...로 넣어 보냈는데도 401이면 JWT 검증 단계에서 aud(Audience) 또는 iss(Issuer)가 불일치하는 경우를 가장 먼저 의심해야 합니다.

이 문제는 단순히 “토큰이 만료됨” 수준이 아니라, 리소스 서버(내 API)가 기대하는 발급자/대상과 실제 토큰의 클레임이 다를 때 발생합니다. 결과적으로 인증 서버에서 발급받은 토큰이 “내 API를 위한 토큰”이 아닌 상태가 되거나, 검증 라이브러리 설정이 잘못되어 정상 토큰도 거부됩니다.

아래는 실무에서 가장 많이 터지는 패턴을 중심으로 aud/iss를 빠르게 점검하고, 프레임워크별로 어떻게 고쳐야 하는지 정리한 글입니다.

401이지만 OAuth는 성공한 상황의 전형적인 흐름

  1. 사용자가 OAuth 로그인(Authorization Code + PKCE 등)을 완료
  2. 프런트가 토큰 엔드포인트에서 access_token을 수령
  3. 프런트가 API 호출 시 Authorization 헤더에 토큰을 첨부
  4. API 서버에서 JWT 서명 검증은 통과하거나(혹은 시도조차 못 하고) 클레임 검증에서 실패
  5. 결과: 401 또는 WWW-Authenticate 헤더에 invalid_token / invalid_token, error="invalid_token" 등 표시

여기서 핵심은 OAuth “인증” 성공과 API “인가/검증” 성공은 별개라는 점입니다. OAuth 서버는 토큰을 발급했지만, 그 토큰이 “이 API에 유효한 토큰”인지 여부는 리소스 서버의 검증 정책에 달려 있습니다.

먼저 토큰을 디코드해서 iss/aud를 확인

가장 빠른 출발점은 실제 액세스 토큰을 디코드해서 issaud 값을 눈으로 확인하는 것입니다.

로컬에서 JWT 페이로드 확인(Node.js)

// decode-jwt.js
const jwt = require('jsonwebtoken');

const token = process.argv[2];
if (!token) {
  console.error('Usage: node decode-jwt.js `ACCESS_TOKEN`');
  process.exit(1);
}

const decoded = jwt.decode(token, { complete: true });
console.log(JSON.stringify(decoded, null, 2));

실행:

node decode-jwt.js `eyJ...`

여기서 확인할 것:

  • payload.iss: 발급자 URL(대개 https://.../ 형태)
  • payload.aud: 문자열 또는 배열
  • payload.azp(있다면): authorized party(클라이언트 ID)
  • payload.scope: 스코프

주의: jwt.decode는 서명 검증을 하지 않습니다. 디버깅용으로만 사용하고, 실제 서버에서는 반드시 서명 검증을 수행해야 합니다.

iss는 “정확히” 일치해야 한다

iss는 보통 다음 중 하나로 나타납니다.

  • https://issuer.example.com/
  • https://issuer.example.com (슬래시 없음)
  • https://issuer.example.com/oauth2/default (Auth0/Okta 등에서 흔함)

검증 라이브러리는 iss문자열 완전 일치로 비교하는 경우가 많습니다. 즉, 슬래시 하나 차이로도 실패할 수 있습니다.

aud는 “이 토큰이 누구를 위해 발급됐는가”

aud는 일반적으로 다음 값 중 하나입니다.

  • API 식별자(예: https://api.example.com)
  • 리소스 서버 ID(예: api://my-service)
  • 클라이언트 ID(일부 공급자/플로우에서)
  • 배열(예: aud: ["api://a", "api://b"])

리소스 서버는 보통 aud에 “내 API의 식별자”가 포함되어야만 통과시키도록 설정합니다.

가장 흔한 원인 7가지 (aud/iss 중심)

1) 프런트가 받은 토큰이 id_token인데 access_token으로 사용

OIDC에서 id_token은 “사용자 인증 결과”이고, access_token은 “API 접근 권한”입니다. 둘은 용도가 다르며, id_tokenaud는 대개 클라이언트 ID입니다.

증상:

  • 프런트는 토큰을 받았다고 생각하지만 API는 계속 401
  • 디코드해 보면 audmy-frontend-client-id로 찍힘

해결:

  • API 호출에는 반드시 access_token 사용
  • 가능하면 백엔드에서 typ/token_use 등을 확인해 토큰 종류를 강제

2) 리소스 서버가 기대하는 aud 값과 실제 토큰의 aud가 다름

예를 들어 백엔드는 audapi://my-service로 검증하는데, 실제 토큰은 aud: https://api.example.com일 수 있습니다.

원인:

  • OAuth 공급자 콘솔에서 API Identifier를 다르게 설정
  • 환경별(DEV/PROD) API Audience가 다른데 설정을 섞음

해결:

  • 공급자 설정에서 “API Audience/Resource”를 명확히 통일
  • 백엔드의 audience 설정을 실제 토큰 값과 맞춤

3) aud가 배열인데 서버가 문자열로만 비교

일부 라이브러리/미들웨어는 aud가 배열인 경우를 제대로 처리하지 못하거나, 설정이 문자열 단일값일 때 비교 로직이 빡빡합니다.

해결:

  • 라이브러리의 audience 옵션이 배열을 지원하는지 확인
  • 또는 검증 로직에서 aud 배열 포함 여부로 판단

4) iss 슬래시(/) 하나 차이, 혹은 테넌트/realm 경로 차이

예:

  • 기대값: https://issuer.example.com/
  • 실제값: https://issuer.example.com

또는 Keycloak의 경우:

  • https://kc.example.com/realms/myrealm

해결:

  • OpenID Provider Configuration(Discovery) 문서의 issuer 값을 기준으로 설정
  • 임의로 iss를 조합하지 말 것

5) 같은 공급자라도 “다른 Authorization Server”에서 발급받음

Okta/Auth0 등은 Authorization Server(또는 Domain/Realm)가 여러 개일 수 있고, 각 서버마다 iss와 JWK가 달라집니다.

증상:

  • 서명 검증 단계에서 실패하거나
  • iss 불일치로 실패

해결:

  • 프런트가 사용하는 issuer와 백엔드가 검증하는 issuer를 동일하게
  • JWKS URL도 같은 issuer의 discovery에서 가져오기

6) 프록시/게이트웨이가 토큰을 다른 서비스로 전달하면서 aud 정책이 어긋남

API Gateway가 토큰을 검증한 뒤 내부 서비스로 넘길 때, 내부 서비스도 다시 검증하면 aud가 내부 서비스 식별자와 맞지 않아 실패할 수 있습니다.

해결 패턴:

  • 게이트웨이에서만 JWT 검증하고 내부는 신뢰(네트워크/MTLS 등으로 보강)
  • 혹은 내부 서비스용 토큰 교환(Token Exchange) 도입
  • 내부 서비스도 동일한 audience 정책을 사용하도록 통일

7) 개발 환경에서 “임시로 끄던 검증 옵션”을 켰는데 값이 불일치

예:

  • 로컬에서는 audience 검증을 끄고 진행
  • 배포 후 audience 검증을 켰더니 실제 토큰의 aud가 다름

해결:

  • 로컬에서도 실제 운영과 동일한 검증 정책으로 테스트
  • 최소한 iss, aud, exp, nbf는 항상 켜두기

체크리스트: 10분 안에 원인 좁히기

  1. API가 받은 토큰이 정말 access_token인지 확인(id_token 혼동 여부)
  2. 토큰 디코드 후 iss 문자열을 복사해 백엔드 설정과 완전 일치 비교
  3. 토큰의 aud가 무엇인지 확인(문자열/배열)
  4. 백엔드의 audience 설정이 실제 aud와 일치하는지 확인
  5. Discovery 문서의 issuer를 기준으로 설정했는지 확인
  6. JWKS URL이 같은 issuer의 discovery에서 나온 값인지 확인
  7. 멀티 환경(DEV/PROD)에서 issuer/audience가 섞이지 않았는지 확인
  8. 게이트웨이/프록시가 토큰을 변형하거나 다른 토큰을 주입하지 않는지 확인
  9. 서버 시간 오차로 nbf/exp가 실패하지 않는지 확인(이건 aud/iss 다음으로 흔함)
  10. 실패 시 WWW-Authenticate 헤더와 서버 로그에 남은 에러 코드를 확인

코드 예제: Express에서 iss/aud를 엄격 검증

아래 예시는 jose 기반으로 JWKS를 가져와 서명 검증 + issuer/audience를 함께 검증합니다.

import express from 'express';
import { jwtVerify, createRemoteJWKSet } from 'jose';

const app = express();

const ISSUER = process.env.OIDC_ISSUER; // 예: `https://issuer.example.com/oauth2/default`
const AUDIENCE = process.env.OIDC_AUDIENCE; // 예: `api://my-service`

const jwksUrl = new URL(`${ISSUER}/v1/keys`); // 공급자별로 다를 수 있음(Discovery 권장)
const JWKS = createRemoteJWKSet(jwksUrl);

async function auth(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : null;

  if (!token) return res.status(401).json({ message: 'Missing bearer token' });

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: ISSUER,
      audience: AUDIENCE,
    });

    req.user = payload;
    return next();
  } catch (e) {
    // 운영에서는 e를 그대로 노출하지 말고, 로그에만 남기세요.
    return res.status(401).json({ message: 'Invalid token', reason: e.code || e.message });
  }
}

app.get('/me', auth, (req, res) => {
  res.json({ sub: req.user.sub, aud: req.user.aud, iss: req.user.iss });
});

app.listen(3000);

포인트:

  • issuer/audience를 명시하지 않으면 “서명만 맞으면 통과” 같은 위험한 상태가 되기 쉽습니다.
  • 반대로 값을 잘못 넣으면 정상 토큰도 401이 됩니다. 그래서 디코드로 실제 값을 먼저 확인하는 게 빠릅니다.

Spring Security에서 aud/iss 불일치 다루기(개념)

Spring Security Resource Server는 issuer-uri 기반으로 설정하면 discovery 문서를 통해 issuer/JWK를 자동으로 맞춥니다. 하지만 aud는 기본으로 강제되지 않는 구성도 있어, 실무에서는 Audience validator를 추가하는 패턴을 많이 씁니다.

application.yml 예시(issuer만 우선 맞추기):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: "https://issuer.example.com/oauth2/default"

여기서도 핵심은 issuer-uri토큰의 iss와 정확히 일치시키는 것입니다. aud까지 강제하려면 프로젝트 정책에 맞춰 validator를 추가하세요.

운영에서 재발을 막는 팁

로그에 iss/aud를 “안전하게” 남겨라

401 원인 분석을 위해, 토큰 전체를 로깅하는 건 위험합니다. 대신 다음만 남겨도 충분합니다.

  • iss, aud, sub, exp, kid

예: kid는 헤더에 있으므로 디코드 시 함께 확인하세요. 키 롤오버 시점에 특히 도움이 됩니다.

프런트-백엔드-인증서버 설정을 한 파일에서 관리

환경 변수 이름을 통일하고, DEV/PROD마다 아래 3종 세트를 함께 관리하세요.

  • OIDC_ISSUER
  • OIDC_AUDIENCE
  • OIDC_CLIENT_ID(프런트)

PKCE 단계에서 막히는 문제도 함께 점검

만약 로그인 자체가 불안정하거나, 간헐적으로 토큰 발급이 실패한다면 invalid_grant 원인도 같이 확인해야 합니다. 특히 PKCE는 코드 검증자/리다이렉트 URI 불일치 등으로 토큰 발급이 실패할 수 있습니다.

결론: 401이면 aud/iss부터 맞춰라

OAuth 로그인 성공 후 401은 “토큰이 이상하다”가 아니라, 대부분 리소스 서버가 기대하는 JWT 클레임과 실제 토큰이 어긋난 설정 문제입니다.

정리하면 우선순위는 다음과 같습니다.

  1. 토큰을 디코드해서 iss/aud를 실제 값으로 확인
  2. 백엔드 검증 설정을 discovery의 issuer와 토큰의 aud에 맞춤
  3. id_token/access_token 혼동을 제거
  4. 멀티 환경/멀티 authorization server에서 설정이 섞이지 않게 고정

이 4가지만 정리해도 “로그인은 되는데 API만 401”류 이슈의 상당수를 빠르게 끝낼 수 있습니다.