- Published on
OAuth 로그인 후 401? JWT aud/iss 불일치 점검
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
OAuth 로그인까지는 잘 되는데, 막상 백엔드 API를 호출하면 401 Unauthorized가 떨어지는 케이스가 자주 있습니다. 특히 프런트에서 액세스 토큰을 정상적으로 받았고(응답에 access_token이 있고), 토큰을 Authorization: Bearer ...로 넣어 보냈는데도 401이면 JWT 검증 단계에서 aud(Audience) 또는 iss(Issuer)가 불일치하는 경우를 가장 먼저 의심해야 합니다.
이 문제는 단순히 “토큰이 만료됨” 수준이 아니라, 리소스 서버(내 API)가 기대하는 발급자/대상과 실제 토큰의 클레임이 다를 때 발생합니다. 결과적으로 인증 서버에서 발급받은 토큰이 “내 API를 위한 토큰”이 아닌 상태가 되거나, 검증 라이브러리 설정이 잘못되어 정상 토큰도 거부됩니다.
아래는 실무에서 가장 많이 터지는 패턴을 중심으로 aud/iss를 빠르게 점검하고, 프레임워크별로 어떻게 고쳐야 하는지 정리한 글입니다.
401이지만 OAuth는 성공한 상황의 전형적인 흐름
- 사용자가 OAuth 로그인(Authorization Code + PKCE 등)을 완료
- 프런트가 토큰 엔드포인트에서
access_token을 수령 - 프런트가 API 호출 시
Authorization헤더에 토큰을 첨부 - API 서버에서 JWT 서명 검증은 통과하거나(혹은 시도조차 못 하고) 클레임 검증에서 실패
- 결과:
401또는WWW-Authenticate헤더에invalid_token/invalid_token, error="invalid_token"등 표시
여기서 핵심은 OAuth “인증” 성공과 API “인가/검증” 성공은 별개라는 점입니다. OAuth 서버는 토큰을 발급했지만, 그 토큰이 “이 API에 유효한 토큰”인지 여부는 리소스 서버의 검증 정책에 달려 있습니다.
먼저 토큰을 디코드해서 iss/aud를 확인
가장 빠른 출발점은 실제 액세스 토큰을 디코드해서 iss와 aud 값을 눈으로 확인하는 것입니다.
로컬에서 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_token의 aud는 대개 클라이언트 ID입니다.
증상:
- 프런트는 토큰을 받았다고 생각하지만 API는 계속 401
- 디코드해 보면
aud가my-frontend-client-id로 찍힘
해결:
- API 호출에는 반드시
access_token사용 - 가능하면 백엔드에서
typ/token_use등을 확인해 토큰 종류를 강제
2) 리소스 서버가 기대하는 aud 값과 실제 토큰의 aud가 다름
예를 들어 백엔드는 aud를 api://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분 안에 원인 좁히기
- API가 받은 토큰이 정말
access_token인지 확인(id_token혼동 여부) - 토큰 디코드 후
iss문자열을 복사해 백엔드 설정과 완전 일치 비교 - 토큰의
aud가 무엇인지 확인(문자열/배열) - 백엔드의
audience설정이 실제aud와 일치하는지 확인 - Discovery 문서의
issuer를 기준으로 설정했는지 확인 - JWKS URL이 같은 issuer의 discovery에서 나온 값인지 확인
- 멀티 환경(DEV/PROD)에서 issuer/audience가 섞이지 않았는지 확인
- 게이트웨이/프록시가 토큰을 변형하거나 다른 토큰을 주입하지 않는지 확인
- 서버 시간 오차로
nbf/exp가 실패하지 않는지 확인(이건aud/iss다음으로 흔함) - 실패 시
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_ISSUEROIDC_AUDIENCEOIDC_CLIENT_ID(프런트)
PKCE 단계에서 막히는 문제도 함께 점검
만약 로그인 자체가 불안정하거나, 간헐적으로 토큰 발급이 실패한다면 invalid_grant 원인도 같이 확인해야 합니다. 특히 PKCE는 코드 검증자/리다이렉트 URI 불일치 등으로 토큰 발급이 실패할 수 있습니다.
결론: 401이면 aud/iss부터 맞춰라
OAuth 로그인 성공 후 401은 “토큰이 이상하다”가 아니라, 대부분 리소스 서버가 기대하는 JWT 클레임과 실제 토큰이 어긋난 설정 문제입니다.
정리하면 우선순위는 다음과 같습니다.
- 토큰을 디코드해서
iss/aud를 실제 값으로 확인 - 백엔드 검증 설정을 discovery의
issuer와 토큰의aud에 맞춤 id_token/access_token혼동을 제거- 멀티 환경/멀티 authorization server에서 설정이 섞이지 않게 고정
이 4가지만 정리해도 “로그인은 되는데 API만 401”류 이슈의 상당수를 빠르게 끝낼 수 있습니다.