콘텐츠로 이동

Authentication vs Authorization

분류: Layer 1 - 백엔드 기초

Authentication(인증)은 “너 누구야?”이고, Authorization(인가)은 “너 이거 해도 돼?”이다.

모든 서비스에는 로그인과 권한이 있다. 사용자 관련 이슈의 상당수가 “인증 실패인지 권한 부족인지”를 구분하는 것에서 시작한다. 이 둘을 혼동하면 디버깅 방향이 완전히 달라진다. AWS IAM도 이 개념 위에 있으므로 인프라 학습의 선수지식이기도 하다.

Authentication(인증, AuthN)

사용자가 누구인지 확인하는 과정. “신분증 검사”와 같다.

  • 방법: ID/PW 로그인, OAuth, SSO, API Key, JWT 토큰 등
  • 결과: 이 사람이 “홍길동”이라는 것을 확인

Authorization(인가, AuthZ)

인증된 사용자가 특정 행동을 할 수 있는지 확인하는 과정. “출입 권한 확인”과 같다.

  • 방법: 역할 기반(RBAC), 정책 기반(PBAC), ACL 등
  • 결과: “홍길동”은 관리자 페이지에 접근할 수 있다 / 없다

JWT(JSON Web Token) — 왜 서버가 DB를 조회하지 않아도 되는가

비유하자면, JWT는 “위조 방지 스탬프가 찍힌 신분증”이다. 신분증을 받은 사람이 스탬프(서명)를 확인해서 진짜인지 검증한다. 발급 기관에 매번 물어볼 필요가 없다.

JWT 서명 동작 원리:

1. 서버가 로그인 시 토큰 생성:
Header(알고리즘) + Payload(사용자 정보) → HMAC-SHA256(secret key) → Signature
2. 토큰 구조:
eyJhbGc.eyJ1c2VySWQ.SflKxwRJSMeKKF2QT4fwpMeJf
↑ Header ↑ Payload ↑ Signature (위조 감지용)
3. 클라이언트가 다음 요청에 토큰 포함:
Authorization: Bearer eyJhbGc...
4. 서버가 Signature만 재계산해서 검증 → DB 조회 없이 사용자 확인

📖 더 보기: JWT.io — JSON Web Tokens Introduction — JWT 구조와 서명 원리를 공식 사이트에서 인터랙티브하게 확인 (입문)

NestJS에서 JWT 인증 구현

// JWT Guard로 보호된 엔드포인트
@Controller("orders")
@UseGuards(JwtAuthGuard) // 모든 엔드포인트에 토큰 검증 적용
export class OrdersController {
@Get()
findAll(@Request() req) {
// req.user에 JWT Payload의 사용자 정보가 들어있음
return this.ordersService.findByUser(req.user.userId);
}
@Delete(":id")
@Roles("admin") // RBAC: admin 역할만 접근 가능
@UseGuards(RolesGuard)
remove(@Param("id") id: string) {
return this.ordersService.remove(+id);
}
}
# 유효한 토큰으로 요청 시 (200 OK)
GET /orders
Authorization: Bearer eyJhbGciOiJIUzI1...
→ 200 OK, 사용자의 주문 목록 반환
# 토큰 없이 요청 시 (401)
GET /orders
→ 401 Unauthorized: {"message": "Unauthorized"}
# 토큰은 있지만 admin 역할 없이 DELETE 시 (403)
DELETE /orders/1
Authorization: Bearer eyJhbGc... (일반 사용자 토큰)
→ 403 Forbidden: {"message": "Forbidden resource"}

Access Token은 짧은 유효기간(15분~1시간)을 가지며, 만료되면 Refresh Token으로 새 토큰을 재발급받는다.

“토큰이 만료됐습니다(401)” 에러를 받으면 토큰 재발급 로직이 작동해야 한다.

⚠️ JWT Payload는 암호화가 아니다

Payload는 Base64로 인코딩된 것이라 누구나 디코딩할 수 있다. Signature는 위조 방지를 하지만, Payload 내용은 보호하지 않는다.

비밀번호, 주민등록번호 등 민감한 정보를 Payload에 넣으면 안 된다

Refresh Token Rotation — 왜 Refresh Token도 한 번만 써야 하는가

비유하자면, Refresh Token Rotation은 “입장권을 쓸 때마다 새 입장권으로 교환해주는 것”이다. 만약 누군가 입장권을 훔쳤더라도, 정상 사용자가 먼저 교환해버리면 훔친 입장권은 즉시 무효가 된다.

Rotation 없이 Refresh Token이 탈취되면, 공격자는 만료 전까지 무제한으로 새 Access Token을 발급받을 수 있다. Rotation이 적용되면 탈취된 Refresh Token을 공격자가 사용하는 순간 서버가 재사용 시도를 감지하고 해당 세션의 모든 토큰을 무효화할 수 있다.

Refresh Token Rotation 흐름:
1. 로그인 → Access Token(15분) + Refresh Token-A(14일) 발급
2. Access Token 만료 → 클라이언트가 Refresh Token-A로 재발급 요청
3. 서버: Refresh Token-A 무효화 + Access Token(15분) + Refresh Token-B(14일) 발급
4. 공격자가 탈취한 Refresh Token-A로 재발급 시도
→ 서버: 이미 사용된 토큰 → 재사용 감지 → 모든 세션 토큰 무효화

권장 토큰 수명:

  • Access Token: 5~15분 (외부 API) / 최대 60분 (내부 저위험 서비스)
  • Refresh Token: 7~14일

JWT jti(JWT ID) 클레임 — 토큰 추적과 강제 만료

각 토큰에 고유 ID(jti)를 부여하면 특정 토큰만 무효화할 수 있다. Redis에 블랙리스트를 두고 로그아웃 시 해당 jti를 기록하면, 아직 만료되지 않은 토큰도 강제로 차단할 수 있다.

// 로그아웃 시 jti를 Redis 블랙리스트에 추가
async logout(token: string) {
const decoded = this.jwtService.decode(token) as any;
const ttl = decoded.exp - Math.floor(Date.now() / 1000); // 남은 유효시간
await this.redis.setex(`blacklist:${decoded.jti}`, ttl, '1');
}
// JwtStrategy에서 블랙리스트 확인
async validate(payload: JwtPayload) {
const isBlacklisted = await this.redis.get(`blacklist:${payload.jti}`);
if (isBlacklisted) throw new UnauthorizedException('Token has been revoked');
return payload;
}

JWT 보안 함정 — 2025년 기준 가장 흔한 실수 3가지

JWT를 사용할 때 반드시 알아야 할 보안 함정이 있다. 2025년 API 보안 감사에서 가장 빈번하게 발견되는 취약점들이다.

  1. alg: none 공격: 초기 JWT 라이브러리는 알고리즘을 none으로 설정한 토큰을 허용했다. 서명이 없으므로 공격자가 Payload를 자유롭게 위조할 수 있다. → 반드시 허용 알고리즘을 화이트리스트로 지정해야 한다.

  2. Claim 검증 누락: iss(발급자), aud(대상), exp(만료) 클레임이 토큰에 있어도 서버가 검증하지 않으면 무용지물이다. 다른 서비스가 발급한 토큰이 내 서비스에서 통과될 수 있다.

  3. 약한 비밀 키: secret, admin123, changeme 같은 추측 가능한 키를 사용하면 브루트포스로 키를 알아낼 수 있다. → 최소 256비트(32자) 이상의 랜덤 키를 사용한다.

// NestJS JwtModule — 안전한 설정
JwtModule.register({
secret: process.env.JWT_SECRET, // 환경변수에서 로드 (32자 이상 랜덤)
signOptions: {
expiresIn: "15m",
algorithm: "HS256", // 알고리즘 명시 (alg:none 방지)
issuer: "my-app", // iss 클레임 자동 추가
audience: "my-api", // aud 클레임 자동 추가
},
});
// JwtStrategy — Claim 검증
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthBearerToken(),
secretOrKey: process.env.JWT_SECRET,
algorithms: ["HS256"], // HS256만 허용 (alg 다운그레이드 차단)
issuer: "my-app", // iss 검증
audience: "my-api", // aud 검증
});
}
}

📖 더 보기: JWT Security Best Practices — Curity — JWT 보안 체크리스트와 흔한 실수를 정리한 가이드 (중급)

분산 시스템에서 JWT 알고리즘 선택 — HS256 vs RS256

  • HS256(HMAC): 하나의 비밀 키로 서명 + 검증. 단일 서버에 적합. 키를 공유해야 하므로 마이크로서비스 환경에서 키 노출 위험이 있다.
  • RS256(RSA): 개인 키로 서명, 공개 키로 검증. 마이크로서비스 환경에서 각 서비스가 공개 키만 갖고 검증 가능. 서명 키를 공유할 필요가 없어 더 안전하다.

BackOps처럼 여러 서비스가 있는 환경에서는 RS256 + 공개 키 배포 방식을 사용하면 각 서비스가 인증 서버에 의존하지 않고 토큰을 자체 검증할 수 있다.

키 로테이션(Key Rotation) 운영 — 무중단 키 전환

HS256(단일 비밀 키)을 사용하는 경우 JWT_SECRET을 교체하면 기존에 발급된 모든 토큰이 즉시 무효화된다. 무중단 전환을 위해 RS256 + JWKS(JSON Web Key Set) 엔드포인트 패턴을 사용한다. (RFC 7517, Okta — Key Rotation)

JWKS 엔드포인트 활용 무중단 키 전환 절차:
1. 새 키 쌍(kid: "key-v2") 생성 → JWKS 엔드포인트에 기존 키(kid: "key-v1")와 함께 노출
GET /.well-known/jwks.json
→ { "keys": [{ "kid": "key-v1", ... }, { "kid": "key-v2", ... }] }
2. 신규 토큰은 key-v2로 서명 (헤더: { "kid": "key-v2" })
기존 key-v1로 서명된 토큰은 유예 기간(예: 14일, Refresh Token 수명) 동안 계속 검증 가능
3. 유예 기간 후 → JWKS에서 key-v1 제거 → 구 키 완전 폐기
검증 서비스: 토큰 헤더의 kid를 확인 → JWKS에서 해당 kid의 공개 키를 조회 → 검증

HS256(단일 비밀 키) 긴급 교체 시 대응: 즉각적인 전체 무효화가 불가피하다. 이 경우 사용자에게 재로그인을 요구하거나, Refresh Token을 이용해 새 비밀 키로 서명된 토큰을 재발급한다. 이것이 HS256이 마이크로서비스 환경에서 불리한 실질적 이유다.

📖 더 보기: Auth0 — Navigating RS256 and JWKS — JWKS 기반 키 로테이션 실무 가이드 (중급)

OAuth 2.0 — 왜 이렇게 동작하는가

비유하자면, OAuth 2.0은 “열쇠를 직접 주지 않고, 특정 방 출입증만 발급해주는 것”이다. 내 Google 비밀번호를 앱에 주는 대신, Google이 “이 앱에게 이메일 읽기 권한만 줘”라는 제한된 출입증을 발급한다.

“Google로 로그인”, “GitHub으로 로그인” 등이 바로 이 방식이다.

Authorization Code Flow 동작 단계:

1. 사용자가 "Google로 로그인" 클릭
→ 앱이 사용자를 Google 로그인 페이지로 리다이렉트
URL: https://accounts.google.com/o/oauth2/auth
?client_id=앱ID
&redirect_uri=https://myapp.com/callback
&response_type=code
&scope=email profile
&state=랜덤문자열(CSRF 방지)
2. 사용자가 Google에서 로그인 + 권한 동의
→ Google이 앱의 callback URL로 단기 코드(Authorization Code) 전달
URL: https://myapp.com/callback?code=4/ABCDEF...&state=랜덤문자열
3. 앱 서버가 코드를 Access Token으로 교환 (서버↔서버, 브라우저 거치지 않음)
POST https://oauth2.googleapis.com/token
{ code, client_id, client_secret, redirect_uri }
→ { access_token, refresh_token, expires_in }
4. 앱이 Access Token으로 Google API 호출
→ 사용자 정보(이메일, 이름) 획득 → 자체 JWT 발급

왜 코드를 한 번 더 교환하는가? 코드(Authorization Code)가 URL에 노출되어도 30초 내에 만료되고 일회성이다. 실제 Access Token은 서버-서버 통신으로만 전달되어 브라우저에 노출되지 않는다. 이것이 이 흐름의 핵심 보안 이유다.

📖 더 보기: Auth0 — Authorization Code Flow — 단계별 다이어그램과 함께 OAuth 2.0 흐름을 설명하는 공식 가이드 (입문)

OIDC(OpenID Connect) — OAuth 2.0 위에 쌓인 인증 레이어

OAuth 2.0은 인가(Authorization) 프로토콜이다. “이 앱이 내 Google Drive에 접근해도 된다”는 리소스 접근 권한을 위임하는 표준이지, “이 사람이 누구인지”를 알려주지는 않는다. (RFC 6749)

OIDC(OpenID Connect)는 OAuth 2.0 위에 인증(Authentication) 레이어를 추가한 표준이다. OAuth 2.0의 Authorization Code Flow를 그대로 사용하되, ID Token이라는 JWT를 추가로 반환해 “이 사람이 누구인지”를 확인한다. (OpenID Connect Core 1.0)

OAuth 2.0만 사용:
Google → 앱에게 Access Token 발급
앱: "이 토큰으로 Google Calendar 읽기" (인가만, 사용자 신원 모름)
OIDC 사용:
Google → 앱에게 Access Token + ID Token 발급
앱: ID Token의 sub(사용자 ID), email 등으로 "이 사람이 누구인지" 확인 (인증 포함)
구분OAuth 2.0OIDC
목적인가 — “이 앱에게 이 리소스 접근을 허용”인증 — “이 사람이 누구인지 확인”
토큰Access Token(리소스 접근용)Access Token + ID Token(사용자 신원용)
사용 예시”Google Drive 파일 읽기” 권한 위임”Google로 로그인” → 사용자 이메일/이름 확인
스코프자유롭게 정의openid 스코프 필수

실제 “Google로 로그인” 흐름: OIDC로 ID Token을 받아 사용자 신원을 확인(인증) → 앱 자체 JWT를 발급 → 이후 API 호출 시 앱 JWT로 인가.

📖 더 보기: OpenID Connect Core 1.0 공식 스펙 — OIDC 표준 전문 (공식), Auth0 — ID Token vs Access Token — 토큰 차이 실무 설명 (입문)

PKCE(Proof Key for Code Exchange) — SPA/모바일 앱의 OAuth 보안 강화

Authorization Code Flow에서 client_secret을 안전하게 보관할 수 없는 환경(브라우저 앱, 모바일 앱)에서 PKCE를 사용한다. 클라이언트가 임의의 code_verifier를 만들고, 그 해시값(code_challenge)을 인증 요청에 포함시킨다. 토큰 교환 시 원본 code_verifier를 제출하면 서버가 해시해서 검증한다. 중간에 코드를 탈취해도 code_verifier 없이는 토큰을 받을 수 없다.

OAuth 2.1 표준(2025년 기준)은 모든 OAuth 클라이언트에 PKCE 사용을 권장한다.

RBAC(Role-Based Access Control)

역할(Admin, Editor, Viewer 등)별로 권한을 묶어서 관리. 가장 흔한 인가 방식.

실무에서 RBAC는 역할(Role)과 권한(Permission)을 분리해서 설계하는 것이 좋다. 역할은 넓은 접근 범주를, 권한은 세부 동작을 제어한다.

// NestJS에서 역할 + 권한 분리 RBAC 패턴
// 역할 Enum 정의
export enum Role {
ADMIN = "admin",
EDITOR = "editor",
VIEWER = "viewer",
}
// 커스텀 데코레이터로 역할 지정
export const Roles = (...roles: Role[]) => SetMetadata("roles", roles);
// RolesGuard 구현
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>("roles", [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true; // 역할 제한이 없으면 통과
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
# 요청 흐름: RBAC 적용 시
클라이언트 → JWT 검증(JwtAuthGuard) → 역할 확인(RolesGuard) → 컨트롤러 실행
# 예시 결과
ADMIN 토큰으로 DELETE /orders/1 → 200 OK
VIEWER 토큰으로 DELETE /orders/1 → 403 Forbidden

JWT 경계 조건 — 크기 제한과 clock skew

JWT는 HTTP 헤더(Authorization: Bearer ...)로 전송되므로 인프라의 헤더 크기 제한에 걸릴 수 있다.

인프라기본 헤더 크기 제한
Nginx (large_client_header_buffers)버퍼 4개 × 8KB (기본값)
AWS ALB최대 40KB (전체 요청 헤더 합산)
Cloudflare헤더 단일 값 16KB

JWT Payload에 역할(role), 권한(permissions), 사용자 메타데이터 등 클레임이 늘어나면 토큰 크기가 1~3KB에 달할 수 있고, 다른 쿠키·헤더와 합산되어 Nginx 기본 버퍼(8KB)를 초과하는 경우가 실제 운영에서 발생한다. (GitHub — supabase/auth #1754)

→ 클레임 수를 최소화하거나, Nginx large_client_header_buffers 값을 조정하거나, 상세 권한 조회는 DB/Redis로 분리하는 패턴을 사용한다.

clock skew(시계 오차): 분산 서버 간 시각 차이로 유효한 토큰이 만료된 것으로 오인되거나, 아직 유효하지 않은 토큰이 통과될 수 있다. RFC 7519는 exp(만료)·nbf(사용 시작) 검증 시 “a few minutes” 이내의 레이웨이(leeway)를 허용한다고 명시하며, 실무에서는 1~5분을 허용 범위로 설정하는 것이 일반적이다. (RFC 7519 §4.1.4)

// NestJS — clock skew 허용 레이웨이 설정 예시
JwtModule.register({
secret: process.env.JWT_SECRET,
verifyOptions: {
clockTolerance: 60, // ±60초 레이웨이 (분산 환경 권장)
},
});

JWT 보안 설계 심화 — alg:none 공격과 RS256 전환 시점

JWT는 겉보기에 단순해 보이지만, 잘못된 설정 하나가 전체 인증을 무력화할 수 있다. 2025년 API 보안 감사에서 가장 빈번하게 발견되는 JWT 취약점 3가지와 방어법이다.

왜 alg:none 공격이 가능한가: JWT 헤더의 alg 필드는 클라이언트가 제어할 수 있다. 초기 JWT 라이브러리들은 헤더의 alg를 그대로 신뢰했다. 공격자가 헤더를 {"alg":"none"}으로 위조하면, 서명 없는 토큰이 통과된다.

// ❌ 취약한 설정 — alg를 헤더에서 읽어 사용
const decoded = jwt.verify(token, secret); // alg 제한 없음
// ✅ 안전한 설정 — 허용 알고리즘 화이트리스트
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: {
expiresIn: "15m",
algorithm: "HS256", // 서명 알고리즘 고정
issuer: "my-app",
audience: "my-api",
},
verifyOptions: {
algorithms: ["HS256"], // HS256만 허용, none/RS256 등 차단
},
});

언제 HS256 → RS256으로 전환해야 하는가: 위의 “분산 시스템에서 JWT 알고리즘 선택 — HS256 vs RS256” 설명을 참조. 마이크로서비스가 4개 이상이거나 외부 서비스가 토큰을 검증해야 하는 경우 RS256으로 전환한다.


  • 사용자 로그인 시스템 (인증)
  • 관리자/일반 사용자 구분 (인가)
  • API 호출 시 토큰 검증 (인증 + 인가)
  • AWS IAM (누가 어떤 리소스에 접근 가능한지)
  • SSO (한 번 로그인으로 여러 서비스 접근)

시나리오 1: “어제까지 되던 API가 갑자기 403이 됩니다”

→ 인증 문제가 아니라 인가 문제. 먼저 확인할 것:

  1. 해당 사용자의 역할(Role)이 변경되었는지 확인
  2. RBAC 정책이 변경(배포)됐는지 확인
  3. jwt.io에서 현재 토큰의 role 필드를 디코딩해서 값 확인

시나리오 2: “특정 사용자만 401 오류가 납니다”

→ 인증 문제. 확인 순서:

  1. 해당 사용자 토큰의 exp 만료 시각 확인
  2. 서버 JWT_SECRET 환경변수가 최근 변경됐는지 확인 (배포 때 누락되면 기존 토큰 전부 무효화)
  3. Redis 블랙리스트에 해당 jti가 있는지 확인 (redis-cli GET "blacklist:<jti>")

시나리오 3: “AWS S3 버킷 접근이 안 됩니다 — Access Denied”

→ IAM 인가 문제. aws sts get-caller-identity로 현재 인증된 IAM Principal을 확인한 후, 해당 Principal의 정책에 s3:GetObject 권한이 있는지 IAM 콘솔에서 확인한다.

Terminal window
# AWS IAM 디버깅 순서
# 1. 현재 인증된 Principal 확인
aws sts get-caller-identity
# 예상 출력:
# {
# "UserId": "AIDA...",
# "Account": "123456789012",
# "Arn": "arn:aws:iam::123456789012:user/my-user"
# }
# 2. 해당 Principal의 정책 확인 (AWS 콘솔)
# IAM → Users → my-user → Permissions → 어떤 정책이 연결됐는지 확인
# 3. S3 접근 거부 원인 시뮬레이션
aws iam simulate-principal-policy \
--policy-source-arn "arn:aws:iam::123456789012:user/my-user" \
--action-names "s3:GetObject" \
--resource-arns "arn:aws:s3:::my-bucket/*"
# 예상 출력 (거부):
# { "EvaluationResults": [{ "EvalDecision": "implicitDeny" }] }
# 예상 출력 (허용):
# { "EvaluationResults": [{ "EvalDecision": "allowed" }] }

시나리오 4: Access Token 자동 갱신 전체 흐름 구현

프론트엔드 출신이라면 “Access Token 만료 시 자동 갱신” 로직을 axios interceptor로 구현해봤을 것이다. 서버 측에서도 같은 로직이 필요하다.

// auth.service.ts — Refresh Token으로 새 Access Token 발급
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private usersService: UsersService,
private redis: Redis,
) {}
async login(userId: number, email: string, role: string) {
const jti = randomUUID(); // 각 토큰에 고유 ID
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{ userId, email, role, jti },
{ expiresIn: "15m", secret: process.env.JWT_ACCESS_SECRET },
),
this.jwtService.signAsync(
{ userId, jti: randomUUID() },
{ expiresIn: "14d", secret: process.env.JWT_REFRESH_SECRET },
),
]);
// Refresh Token을 Redis에 저장 (유효한 토큰 추적)
await this.redis.setex(`refresh:${userId}`, 14 * 24 * 3600, refreshToken);
return { accessToken, refreshToken };
}
async refreshTokens(refreshToken: string) {
let payload: any;
try {
payload = await this.jwtService.verifyAsync(refreshToken, {
secret: process.env.JWT_REFRESH_SECRET,
});
} catch {
throw new UnauthorizedException("만료되거나 유효하지 않은 Refresh Token");
}
// Redis에 저장된 토큰과 비교 (Rotation 구현)
const storedToken = await this.redis.get(`refresh:${payload.userId}`);
if (storedToken !== refreshToken) {
// 이미 사용된 토큰 재사용 시도 → 모든 세션 무효화
await this.redis.del(`refresh:${payload.userId}`);
throw new UnauthorizedException("토큰 재사용 감지 — 모든 세션 로그아웃");
}
const user = await this.usersService.findOne(payload.userId);
return this.login(user.id, user.email, user.role); // 새 토큰 쌍 발급
}
}
// auth.controller.ts — Refresh Token 엔드포인트
@Controller("auth")
export class AuthController {
@Post("refresh")
async refresh(@Body("refreshToken") refreshToken: string) {
return this.authService.refreshTokens(refreshToken);
}
}

예상 동작:

Terminal window
# 1. 로그인
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@test.com","password":"secret"}'
# → { "accessToken": "eyJ...(15분 유효)", "refreshToken": "eyJ...(14일 유효)" }
# 2. Access Token 만료 후 갱신
curl -X POST http://localhost:3000/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken": "eyJ...(기존 Refresh Token)"}'
# → { "accessToken": "eyJ...(새 15분)", "refreshToken": "eyJ...(새 14일)" }
# 3. 이미 사용한 Refresh Token으로 재시도
# → 401 Unauthorized: {"message": "토큰 재사용 감지 — 모든 세션 로그아웃"}

📖 더 보기: Why Your NestJS JWT Authentication is Probably Broken — DEV — JWT 설정의 흔한 실수와 보안 강화 방법 (중급)

  • 401/403 에러 대응 시 인증 문제인지 권한 문제인지 구분해야 함
  • AWS IAM 정책을 이해하려면 인증/인가 개념이 필수
  • 사내 서비스 간 API 호출 시 토큰 관리 구조 이해
  • 사용자 권한 관련 이슈 리포트 대응

Session vs JWT — 언제 무엇을 선택하는가

섹션 제목: “Session vs JWT — 언제 무엇을 선택하는가”

Session은 서버가 로그인 상태를 메모리(또는 DB/Redis)에 보관하는 방식이다. 요청마다 서버가 세션 저장소를 조회해야 하므로 서버 상태 유지 비용이 발생한다. 반면 로그아웃 시 서버에서 세션을 즉시 삭제하면 토큰이 무효화되므로 로그아웃 즉시 반영이 쉽다. 단점은 수평 확장 시 모든 인스턴스가 같은 세션 저장소를 바라봐야 하므로 Redis 같은 공유 스토어가 필요하다.

JWT는 인증 정보가 토큰 자체에 담겨 있어 서버가 별도 상태를 저장하지 않는다. 서버 확장 시 토큰 검증만 하면 되므로 수평 확장이 용이하다. 단점은 만료 전에 토큰을 즉시 무효화하기 어렵다 — 로그아웃을 즉시 반영하려면 Redis 블랙리스트(jti)를 사용해야 하며, 그 순간 stateless 이점이 일부 사라진다.

선택 기준: 단순한 서버 하나 + 즉시 로그아웃이 중요한 서비스는 Session이 낫다. 수평 확장이 빈번하고 마이크로서비스 간 인증을 공유해야 한다면 JWT + Refresh Token Rotation이 적합하다.

개념 A개념 B차이점
AuthenticationAuthorization인증 = 누구인지 확인, 인가 = 뭘 할 수 있는지 확인
401403401 = 인증 실패(로그인 안 함), 403 = 인가 실패(권한 부족)
JWTSessionJWT는 토큰 자체에 정보 포함(stateless), Session은 서버에 저장(stateful)
OAuth 2.0OIDCOAuth 2.0은 인가(리소스 접근 위임) 프로토콜, OIDC는 그 위에 인증(신원 확인) 레이어 추가
ID TokenAccess TokenID Token: 사용자 신원(누구인지), Access Token: 리소스 접근 권한(뭘 할 수 있는지)
OAuthSSOOAuth는 인증 위임 프로토콜, SSO는 한 번 로그인으로 여러 서비스 접근하는 개념
API KeyJWTAPI Key는 단순 문자열(누가 쓰는지만), JWT는 사용자 정보+만료시간+서명 포함
HS256RS256HS256은 단일 비밀 키(단순), RS256은 공개/개인 키 쌍(마이크로서비스에 적합)
Refresh TokenAccess TokenAccess Token은 짧은 수명(15분), Refresh Token은 긴 수명(14일)으로 재발급용

🔧 “Unknown authentication strategy ‘jwt’” 에러

섹션 제목: “🔧 “Unknown authentication strategy ‘jwt’” 에러”

증상: 서버 시작 시 또는 첫 요청 시 Unknown authentication strategy 'jwt' 에러 발생

원인: JwtModule 또는 PassportModule이 해당 모듈에 import되지 않았거나, JwtStrategyproviders에 등록되지 않음

해결:

// auth.module.ts — 필수 설정 확인
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: "1h" },
}),
],
providers: [AuthService, JwtStrategy], // JwtStrategy 반드시 providers에 추가
exports: [AuthService],
})
export class AuthModule {}

JwtStrategy를 만들었어도 providers에 넣지 않으면 Passport가 인식 못한다.


🔧 토큰이 있는데 계속 401 반환

섹션 제목: “🔧 토큰이 있는데 계속 401 반환”

증상: Authorization: Bearer <token> 헤더를 보냈는데 401이 옴

원인 3가지:

  1. 토큰이 만료됨 (exp 필드가 현재 시각보다 이전)
  2. 토큰 서명에 사용한 JWT_SECRET이 서버와 다름 (환경변수 누락)
  3. Bearer 접두사 없이 토큰만 전송 (Authorization: <token>)

해결:

Terminal window
# 1. 토큰 만료 여부 확인 (jwt.io 또는 터미널)
# Payload의 exp 필드를 https://www.epochconverter.com/ 에서 변환해서 확인
# 2. 환경변수 확인
echo $JWT_SECRET # 비어있으면 문제
# 3. 올바른 헤더 형식
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# ↑ 반드시 "Bearer " 포함 (공백 포함)

🔧 인증은 됐는데 403 — 역할(Role) 문제

섹션 제목: “🔧 인증은 됐는데 403 — 역할(Role) 문제”

증상: 로그인 후 토큰을 받아서 사용 중인데, 특정 엔드포인트에서 403 응답

원인: RolesGuard가 토큰의 role 필드를 확인했을 때 필요한 역할이 없음. 또는 토큰 발급 시 role을 Payload에 포함하지 않음

해결:

// 로그인 시 역할을 Payload에 포함해서 토큰 발급
async login(user: User) {
const payload = {
userId: user.id,
email: user.email,
role: user.role, // ← 반드시 포함
};
return { access_token: this.jwtService.sign(payload) };
}

jwt.io에서 현재 토큰을 디코딩해서 role 필드가 있는지 확인하는 것이 가장 빠른 디버깅 방법이다.


🔧 OAuth 콜백 후 state 검증 실패 또는 CSRF 에러

섹션 제목: “🔧 OAuth 콜백 후 state 검증 실패 또는 CSRF 에러”

증상: OAuth 로그인 후 콜백 처리 단계에서 Invalid state 또는 인증이 완료되지 않고 에러 발생

원인: OAuth Authorization Code Flow에서 state 파라미터를 생성·저장하지 않거나, 콜백에서 검증하지 않음. 또는 Passport OAuth 전략의 callbackURL이 실제 리다이렉트 URI와 불일치

해결:

// NestJS Passport Google OAuth 전략 설정
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
constructor() {
super({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "http://localhost:3000/auth/google/callback", // Google Cloud Console에 등록된 URI와 정확히 일치해야 함
scope: ["email", "profile"],
});
}
async validate(accessToken: string, refreshToken: string, profile: any) {
return { email: profile.emails[0].value, name: profile.displayName };
}
}

Google Cloud Console의 “승인된 리다이렉션 URI”에 callbackURL 값이 등록되어 있는지 반드시 확인해야 한다.


🔧 Refresh Token 재사용 — 로그아웃 후에도 토큰이 유효함

섹션 제목: “🔧 Refresh Token 재사용 — 로그아웃 후에도 토큰이 유효함”

증상: 로그아웃했는데 기존 Access Token으로 API 호출이 계속 성공함. 또는 Refresh Token을 탈취당한 것으로 의심됨

원인: JWT는 stateless라서 서버가 발급 후 토큰을 추적하지 않음. 로그아웃 로직이 클라이언트 측 토큰 삭제만 하고, 서버 측 무효화를 하지 않음

해결: Redis 블랙리스트로 강제 무효화

Terminal window
# Redis에 블랙리스트 등록 여부 확인
redis-cli GET "blacklist:<jti값>"
# 빈 응답이면 블랙리스트에 없음 → 토큰이 여전히 유효
# "1"이면 블랙리스트에 있음 → 토큰 차단됨

Refresh Token Rotation을 적용하면 탈취된 토큰이 재사용되는 순간 서버가 감지하고 해당 사용자의 모든 세션을 무효화할 수 있다.

  • Authentication과 Authorization의 차이를 한 문장으로 설명할 수 있다
  • 401과 403의 차이를 설명할 수 있다
  • JWT가 뭔지, 왜 쓰는지 설명할 수 있다
  • RBAC이 뭔지 설명할 수 있다
  • AWS IAM이 인증/인가 중 어디에 해당하는지 설명할 수 있다
  • OAuth 2.0 Authorization Code Flow의 단계를 순서대로 설명할 수 있다
  • OIDC가 OAuth 2.0과 어떻게 다른지, ID Token이 무엇인지 설명할 수 있다
  • Refresh Token Rotation이 왜 보안상 중요한지 설명할 수 있다
  • HS256과 RS256의 차이를 설명할 수 있다

SAML, OIDC(OpenID Connect), MFA(다중인증), Token Refresh, CORS와 인증, Zero Trust, PKCE, jti 클레임, RS256 vs HS256

  • 팀 서비스의 인증 방식 확인 (JWT? Session? OAuth?)
  • API 호출 시 Authorization 헤더에 뭐가 들어가는지 확인
  • AWS IAM 콘솔에서 내 계정의 역할/권한 확인
  • jwt.io 에서 JWT 토큰 디코딩해보기
Terminal window
# jwt.io 없이 터미널에서 JWT Payload 디코딩하기
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc0MzUwMDAwMH0.xxx"
echo $TOKEN | cut -d '.' -f2 | base64 -d 2>/dev/null
# 예상 출력:
# {"userId":1,"role":"admin","exp":1743500000}
Terminal window
# Refresh Token 재발급 요청 예시
curl -X POST https://api.example.com/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "eyJhbGc..."}'
# 예상 출력 (Rotation 적용 시):
# {
# "access_token": "eyJhbGciOiJIUzI1...(새 토큰)",
# "refresh_token": "eyJhbGciOiJIUzI1...(새 Refresh Token, 기존 것은 무효화됨)"
# }
# 이미 사용한 Refresh Token 재사용 시도:
# → 401 Unauthorized: {"message": "Refresh token already used or revoked"}

10. 요약 — 이것만 기억해도 된다

섹션 제목: “10. 요약 — 이것만 기억해도 된다”
질문
401이 뜨면?인증 문제 — 토큰이 없거나 만료됨
403이 뜨면?인가 문제 — 토큰은 있지만 권한이 없음
JWT가 왜 DB 조회 없이 검증되나?서버 비밀 키로 서명된 구조라서
Payload에 뭘 넣으면 안 되나?비밀번호, 주민번호 등 민감한 정보
OAuth 2.0이 뭔가?비밀번호 안 주고 제3자 인증을 위임하는 표준
1. 사용자 로그인 → JWT Access Token(15분) + Refresh Token(14일) 발급
2. Access Token 만료 → 자동으로 Refresh Token으로 재발급
3. 로그아웃 → jti를 Redis 블랙리스트에 등록 (강제 무효화)
4. 관리자 기능 → @Roles('admin') + RolesGuard로 RBAC 적용
5. AWS IAM 정책 읽을 때 → "이 Principal이 인증됐나? + 이 Action 인가됐나?" 구분
  1. 인증(AuthN)은 “누구인지”, 인가(AuthZ)는 “뭘 할 수 있는지”이다
  2. 401은 인증 실패, 403은 인가 실패 — 디버깅 방향이 완전히 다르다
  3. JWT는 stateless 인증(서버 DB 조회 불필요), Session은 stateful 인증(서버에 상태 저장)
  4. OAuth 2.0은 비밀번호를 직접 받지 않고 제3자 인증을 위임하는 표준 프로토콜이다
  5. AWS IAM, API 토큰, SSO, Refresh Token 모두 이 두 개념 위에 있다