
이 장을 마치면
- 로그인에서 "이메일이 없음" 과 "비밀번호가 틀림" 을 같은 에러, 비슷한 응답 시간으로 만들어 가입 여부가 새지 않게 할 수 있다.
@nestjs/jwt로 액세스 토큰을 발급하고, 알고리즘·발급자·대상을 고정해 검증할 수 있다.alg: none토큰, 다른aud로 서명한 토큰, 만료된 토큰을 직접 만들어 보내고 모두 401 로 막히는 것을 확인할 수 있다.- 가드와 파라미터 데코레이터로 API 를 보호하고, 모듈 순환 의존을 피하도록 토큰 모듈을 분리할 수 있다.
- 로그인 시도 횟수를 제한하고, 제한에 걸린 응답도 이 책의 에러 모양으로 내려보낼 수 있다.
들어가며
4장에서 사용자가 가입할 수 있게 됐습니다. 이제 그 사용자가 로그인하고, 로그인했다는 사실을 증명하는 액세스 토큰을 받아, 보호된 API 를 부르게 만듭니다.
2장에서 액세스 토큰을 JWT 로 만들기로 했습니다. JWT 는 서버가 서명한 JSON 이라서, 서버는 DB 를 보지 않고 서명만 확인해 "이 토큰은 내가 발급했고, 만료되지 않았고, 이 사용자의 것이다" 를 판단할 수 있습니다. 편리한 만큼 검증을 허술하게 하면 치명적입니다. 서명을 확인하는 방법을 토큰 스스로 고르게 두거나, 다른 서비스를 위해 발급된 토큰을 받아 주면, 공격자는 서명 키 없이도 토큰을 통과시킬 수 있습니다. 이 장에서는 검증 옵션을 하나씩 고정하고, 실제로 공격 토큰을 만들어 보내 그것이 막히는 것을 확인합니다.
로그인 자체에도 조심할 곳이 두 군데 있습니다. 하나는 로그인 응답이 가입 여부를 흘리지 않게 하는 것이고, 다른 하나는 비밀번호를 무한정 대입해 보지 못하게 막는 것입니다. 앞의 것은 응답 시간까지 맞춰야 하고, 뒤의 것은 요청 횟수 제한으로 해결합니다.
이 장에서는 아직 리프레시 토큰이 없습니다. 로그인하면 액세스 토큰만 받고, 그것이 만료되면 다시 로그인해야 합니다. 리프레시 토큰은 6장에서 붙입니다.
이 장에서 만들거나 고치는 파일은 다음과 같습니다.
api/src/
├── users/
│ ├── users.controller.ts ← 새 파일: GET·PATCH /users/me
│ └── users.module.ts ← TokenModule import, 컨트롤러 등록
└── auth/
├── password.service.ts ← burn 추가
├── dto.ts ← LoginDto, TokenResponseDto 추가
├── token.service.ts ← 새 파일
├── jwt-auth.guard.ts ← 새 파일
├── token.module.ts ← 새 파일
├── login-throttler.guard.ts ← 새 파일
├── auth.service.ts ← login 추가
├── auth.controller.ts ← login 추가
└── auth.module.ts ← TokenModule, ThrottlerModule 추가
5.1 로그인은 무엇을 흘리는가 — 타이밍 공격
로그인 코드를 가장 단순하게 쓰면 이렇게 됩니다.
// 이렇게 하지 않는다
const user = await this.users.findByEmail(dto.email);
if (!user) throw new AppError(401, ErrorCode.INVALID_CREDENTIALS, '…');
if (!(await this.passwords.verify(user.password_hash, dto.password))) {
throw new AppError(401, ErrorCode.INVALID_CREDENTIALS, '…');
}
두 경우 모두 같은 코드 INVALID_CREDENTIALS 와 같은 메시지를 돌려줍니다. "이메일이 없습니다" 와 "비밀번호가 틀렸습니다" 를 구분해서 알려 주면 누구나 가입 여부를 확인할 수 있으니, 하나로 합치는 것은 기본입니다.
그런데 이 코드에는 여전히 새는 곳이 있습니다. 걸리는 시간이 다릅니다. 이메일이 없으면 DB 조회 한 번(수 밀리초) 만에 응답합니다. 이메일이 있으면 그 뒤에 argon2 검증을 합니다. 4장에서 일부러 느리게 만든 바로 그 계산이라 수십 밀리초가 더 걸립니다. 공격자는 응답 본문을 볼 필요도 없이 응답 시간만 재면 됩니다. 5밀리초 만에 온 401 은 "없는 이메일", 50밀리초 만에 온 401 은 "있는 이메일, 틀린 비밀번호" 입니다. 이렇게 처리 시간의 차이로 비밀을 알아내는 것을 타이밍 공격이라고 부릅니다.
해결책은 없는 이메일일 때도 비밀번호 검증 한 번 분량의 시간을 쓰는 것입니다. 미리 만들어 둔 가짜 해시로 argon2 검증을 돌리고 결과는 버립니다. 이것을 더미 해시라고 부릅니다.
PasswordService.burn
4장의 PasswordService 에 메서드 하나와 필드 하나를 더합니다. 부록 C 의 최종 모습과 같아집니다.
// api/src/auth/password.service.ts
import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
// OWASP Password Storage Cheat Sheet 의 argon2id 최소 권장값 (19 MiB, 2회, 병렬 1)
const OPTIONS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 } as const;
@Injectable()
export class PasswordService {
private dummyHash?: Promise<string>;
hash(password: string): Promise<string> {
return argon2.hash(password, OPTIONS);
}
verify(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password);
}
/** 없는 사용자일 때도 검증 한 번 분량의 시간을 쓴다. 응답 시간으로 가입 여부가 드러나지 않게. */
async burn(password: string): Promise<false> {
this.dummyHash ??= argon2.hash('timing-equalizer-dummy-password', OPTIONS);
await argon2.verify(await this.dummyHash, password);
return false;
}
}
짧은 메서드지만 한 줄 한 줄에 이유가 있습니다.
더미 해시를 코드에 박아 두지 않고 직접 만듭니다. '$argon2id$v=19$m=19456,…' 같은 문자열을 상수로 적어 두는 방법도 있습니다. 하지만 그러면 나중에 OPTIONS 의 메모리나 반복 횟수를 올렸을 때 더미 해시만 예전 파라미터로 남습니다. 진짜 사용자의 검증은 느려졌는데 더미 검증은 예전 속도라, 시간 차이가 다시 생깁니다. 같은 OPTIONS 로 직접 만들면 두 검증의 비용이 늘 같습니다.
??= 로 한 번만 만듭니다. ??= 는 왼쪽이 null 이나 undefined 일 때만 오른쪽 값을 대입합니다. 첫 호출에서만 해시를 만들고, 그 뒤로는 만들어 둔 것을 씁니다. 여기서 저장하는 것이 해시 문자열이 아니라 Promise 라는 점이 중요합니다. 서버가 뜨자마자 없는 이메일로 로그인 요청 두 개가 동시에 오면, 첫 요청이 해시를 만드는 동안 두 번째 요청이 들어옵니다. 문자열을 저장하는 방식이라면 두 번째 요청도 아직 값이 없으니 해시를 또 만듭니다. Promise 를 저장하면 두 번째 요청은 첫 요청이 만들고 있는 Promise 를 받아 같이 기다립니다. 해시는 정확히 한 번만 만들어집니다.
💡 첫 호출은 해시를 만들고(hash) 검증까지(verify) 하므로 평소의 두 배쯤 걸립니다. 서버를 띄운 뒤 첫 번째 "없는 이메일" 로그인만 그렇습니다. 응답 시간을 잴 때 첫 요청은 빼고 봅니다.
검증 결과를 쓰지 않고 false 를 돌려줍니다. argon2.verify 의 결과를 그대로 돌려주면 안 됩니다. 누군가 비밀번호 칸에 정확히 timing-equalizer-dummy-password 를 입력하면 검증이 true 가 되기 때문입니다. 없는 이메일인데 비밀번호가 맞았다는, 말이 안 되는 결과가 나옵니다. 그래서 검증은 시간을 쓰는 용도로만 돌리고 답은 언제나 false 입니다. 반환 타입을 boolean 이 아니라 리터럴 타입 false 로 적어 이 의도를 타입에도 새겼습니다.
5.2 로그인 요청과 응답 — LoginDto, TokenResponseDto
4장의 auth/dto.ts 에 두 클래스를 더합니다.
// api/src/auth/dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, Length, MaxLength } from 'class-validator';
import { UserDto, trim } from '../users/dto';
// … normalizeEmail, SignupDto 는 4장과 같다
export class LoginDto {
@ApiProperty({ example: 'kim@example.com' })
@Transform(normalizeEmail)
@IsEmail()
email!: string;
@ApiProperty({ example: 'correct horse battery' })
@IsString()
@IsNotEmpty()
@MaxLength(128)
password!: string;
}
export class TokenResponseDto {
@ApiProperty({ description: 'JWT. 메모리에만 보관한다' })
accessToken!: string;
@ApiProperty({ enum: ['Bearer'] })
tokenType!: 'Bearer';
@ApiProperty({ example: 600, description: '액세스 토큰 유효 시간(초)' })
expiresIn!: number;
@ApiProperty({ type: UserDto })
user!: UserDto;
}
import 줄이 두 군데 바뀌었습니다. class-validator 에서 IsNotEmpty 를 더 가져오고, ../users/dto 에서 UserDto 를 더 가져옵니다.
LoginDto 는 가입보다 느슨하다
LoginDto 를 SignupDto 와 나란히 놓고 보면 비밀번호 규칙이 다릅니다. 가입은 @Length(8, 128) 인데 로그인은 @IsNotEmpty() 와 @MaxLength(128) 뿐입니다. 최소 길이를 검사하지 않습니다.
로그인에서 최소 길이를 검사하면 두 가지 문제가 생깁니다. 첫째, 비밀번호 정책은 바뀝니다. 오늘 최소 길이를 12자로 올리면, 어제 8자로 가입한 사용자는 로그인조차 못 하게 됩니다. 정책 변경은 새로 만드는 비밀번호에만 적용되어야 합니다. 둘째, 7자짜리 비밀번호로 로그인을 시도했을 때 400 VALIDATION_FAILED 가 오면 "이 서비스의 최소 길이는 8자다" 라는 정보가 새어 나갑니다. 큰 비밀은 아니지만 굳이 알려 줄 이유도 없습니다. 틀린 비밀번호는 길이와 상관없이 401 INVALID_CREDENTIALS 로 답합니다.
최대 길이 128자는 남겨 둡니다. 로그인 요청은 인증 전에 누구나 보낼 수 있고, 모두 argon2 검증을 거칩니다. 수 메가바이트짜리 비밀번호로 검증 비용을 부풀리는 요청을 여기서 걸러 냅니다.
이메일은 가입과 같은 normalizeEmail 을 거칩니다. 가입할 때 소문자로 저장했으니 찾을 때도 소문자로 찾아야 합니다.
TokenResponseDto
로그인 응답의 모양입니다. 네 필드 모두 이유가 있습니다.
accessToken 은 JWT 문자열입니다. 설명에 "메모리에만 보관한다" 를 적어 두었습니다. 11장에서 다루지만, 프론트엔드가 이 값을 localStorage 에 넣으면 XSS 한 번에 토큰이 통째로 빠져나갑니다. 문서를 읽는 프론트엔드 개발자가 가장 먼저 보는 곳에 이 규칙을 남깁니다.
tokenType 은 언제나 'Bearer' 입니다. OAuth 2.0 의 토큰 응답(RFC 6749)이 쓰는 관례를 따른 것으로, "이 토큰은 Authorization: Bearer <토큰> 으로 보낸다" 는 뜻입니다. TypeScript 타입을 string 이 아닌 'Bearer' 리터럴로, Swagger 에는 enum: ['Bearer'] 로 적었습니다. 10장에서 이 문서로 프론트엔드 타입을 만들면 tokenType: 'Bearer' 가 그대로 옮겨집니다.
expiresIn 은 액세스 토큰의 유효 시간(초)입니다. JWT 안의 exp 를 읽어도 알 수 있지만, 프론트엔드가 토큰을 해독하지 않고도 알 수 있게 따로 줍니다.
user 는 로그인한 사용자 정보입니다. 로그인 직후 화면에 닉네임을 보여 주려고 /users/me 를 한 번 더 부르지 않아도 되게 합니다. @ApiProperty({ type: UserDto }) 로 중첩된 객체의 스키마를 알려 줍니다.
5.3 토큰 서비스 — 발급과 검증
JWT 를 서명하고 검증하는 코드를 TokenService 에 모읍니다.
// api/src/auth/token.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'node:crypto';
import { AppConfig } from '../config/app-config';
export type AuthUser = { userId: string; sessionId: string };
@Injectable()
export class TokenService {
constructor(
private readonly jwt: JwtService,
private readonly config: AppConfig,
) {}
signAccessToken(userId: string, sessionId: string): Promise<string> {
return this.jwt.signAsync(
{ sid: sessionId },
{
secret: this.config.jwtAccessSecret,
algorithm: 'HS256',
subject: userId,
issuer: this.config.jwtIssuer,
audience: this.config.jwtAudience,
expiresIn: this.config.accessTokenTtlSec,
jwtid: randomUUID(),
},
);
}
async verifyAccessToken(token: string): Promise<AuthUser> {
const payload = await this.jwt.verifyAsync<{ sub: string; sid: string }>(token, {
secret: this.config.jwtAccessSecret,
algorithms: ['HS256'], // 토큰 헤더의 alg 를 믿지 않는다
issuer: this.config.jwtIssuer,
audience: this.config.jwtAudience,
});
return { userId: payload.sub, sessionId: payload.sid };
}
}
💡 부록 C 의 TokenService 에는 리프레시 토큰을 만들고 해싱하는 newRefreshToken·hashRefreshToken 이 더 있습니다. 6장에서 추가하며, 그때 node:crypto 에서 가져오는 함수도 늘어납니다.
AuthUser
검증을 통과한 토큰에서 꺼낸 정보의 모양입니다. 컨트롤러와 서비스는 JWT 의 클레임 이름(sub, sid)을 몰라도 되게, 여기서 userId, sessionId 라는 이름으로 바꿔 줍니다. 4장의 toUserDto 가 DB 의 이름을 API 의 이름으로 바꾼 것과 같은 경계입니다.
signAccessToken — 무엇을 담는가
signAsync 의 첫 인자는 페이로드, 둘째 인자는 옵션입니다. 페이로드에는 sid 만 직접 넣고, 나머지 클레임은 옵션으로 넘깁니다. @nestjs/jwt 가 옵션을 표준 클레임으로 바꿔 넣어 줍니다. 결과 토큰에 들어가는 클레임을 정리하면 이렇습니다.
| 클레임 | 옵션 | 값 | 뜻 |
|---|---|---|---|
sub | subject | 사용자 id | 이 토큰의 주인 |
sid | (페이로드) | 세션 id | 어느 로그인에서 나온 토큰인가. 6장에서 리프레시 토큰 family 와 연결된다 |
jti | jwtid | 무작위 UUID | 토큰마다 다른 고유 id |
iss | issuer | auth-lab-api | 누가 발급했는가 |
aud | audience | auth-lab-web | 누구에게 쓰라고 발급했는가 |
iat | (자동) | 발급 시각 | |
exp | expiresIn | 발급 시각 + 600 | 만료 시각 |
expiresIn 에 숫자를 주면 초 단위로 해석됩니다. '10m' 같은 문자열도 받지만, 3장에서 설정값을 _SEC 로 통일했으니 숫자를 그대로 넘깁니다.
jti 는 지금 당장 검증에 쓰지 않습니다. 토큰마다 고유한 id 가 있으면 로그에서 특정 토큰의 사용 기록을 추적할 수 있고, 나중에 특정 토큰 하나만 막는 차단 목록을 만들 때 열쇠로 쓸 수 있습니다. 같은 사용자가 같은 초에 두 번 로그인해도 두 토큰이 문자열로 달라진다는 부수 효과도 있습니다.
비밀번호, 이메일, 닉네임 같은 정보는 넣지 않습니다. JWT 의 페이로드는 암호화되지 않습니다. base64url 로 인코딩됐을 뿐이라 누구나 풀어 볼 수 있습니다(2장에서 손으로 해부해 봤습니다). 서명은 내용을 숨기는 것이 아니라 바뀌지 않았음을 보장할 뿐입니다.
algorithm: 'HS256'
HS256 은 HMAC-SHA256 으로 서명합니다. 서명하는 쪽과 검증하는 쪽이 같은 비밀 키를 씁니다(대칭 키). 이 책처럼 토큰을 발급하는 서버와 검증하는 서버가 같을 때는 가장 단순하고 빠른 선택입니다. 검증하는 서비스가 여럿으로 늘어나면 모든 서비스에 비밀 키를 나눠 줘야 하고, 그중 하나만 뚫려도 공격자가 토큰을 발급할 수 있게 됩니다. 그때는 개인 키로 서명하고 공개 키로 검증하는 RS256 이나 EdDSA 로 옮깁니다. 14장에서 다시 다룹니다.
verifyAccessToken — 무엇을 고정하는가
검증 옵션에 네 가지를 넘깁니다. secret 은 당연히 필요하고, 나머지 셋이 이 장의 핵심입니다.
algorithms: ['HS256']. JWT 의 헤더에는 "alg": "HS256" 처럼 서명 알고리즘이 적혀 있습니다. 문제는 이 헤더를 공격자가 쓴다는 점입니다. 검증하는 쪽이 "헤더에 적힌 알고리즘으로 검증한다" 고 하면, 공격자가 알고리즘을 고르게 됩니다. JWT 의 역사에서 이 때문에 생긴 사고가 두 가지 유명합니다.
alg: none. JWT 표준에는 "서명 없음" 을 뜻하는none알고리즘이 정의되어 있습니다. 헤더를{"alg":"none"}으로 바꾸고 서명 부분을 비운 토큰을 보냈을 때, 헤더를 믿는 라이브러리는 "서명이 없는 토큰이니 서명 검사를 건너뛴다" 고 판단했습니다. 공격자는 페이로드를 마음대로 쓰고 서명 없이 통과했습니다.- 알고리즘 바꿔치기. 서버가 RS256(공개 키로 검증)을 쓰는데, 공격자가 헤더를 HS256 으로 바꾸고 서버의 공개 키를 HMAC 비밀 키로 삼아 서명합니다. 헤더를 믿는 라이브러리는 HS256 으로 검증하면서 "검증 키" 자리에 있던 공개 키를 비밀 키로 씁니다. 공개 키는 말 그대로 공개되어 있으니 누구나 유효한 토큰을 만들 수 있습니다.
지금 널리 쓰이는 JWT 라이브러리들은 이런 공격을 막는 쪽으로 기본값을 바꿔 왔습니다. @nestjs/jwt 가 내부에서 쓰는 라이브러리도 과거 버전에서 알고리즘 관련 취약점이 보고되어 고쳐진 이력이 있습니다. 그래도 이 책은 기본값에 기대지 않고 알고리즘을 명시적으로 고정합니다. 우리가 서명할 때 쓰는 알고리즘은 HS256 하나뿐이니, 검증할 때도 HS256 하나만 받습니다. 라이브러리의 기본값이 버전에 따라 바뀌어도 이 서버의 동작은 바뀌지 않습니다.
issuer, audience. 서명이 맞고 만료되지 않았더라도, iss 와 aud 가 우리 설정과 다르면 거부합니다. 서명 키가 맞으면 우리가 발급한 것인데 왜 이것까지 확인해야 할까요.
같은 비밀 키가 여러 곳에서 쓰이는 일이 생각보다 흔하기 때문입니다. 한 회사가 서비스 두 개를 만들면서 같은 JWT 비밀 키를 복사해 쓰는 경우, 개발 서버와 스테이징 서버가 같은 .env 를 공유하는 경우, 관리자 도구용 토큰과 일반 사용자용 토큰을 같은 키로 서명하는 경우가 있습니다. 이때 aud 를 확인하지 않으면 다른 서비스를 위해 발급된 토큰이 우리 서비스에서 통과합니다. 관리자 도구에서 발급받은 토큰으로 일반 API 를 부르거나, 스테이징에서 받은 토큰으로 운영 API 를 부르는 일이 가능해집니다. aud 는 "이 토큰은 auth-lab-web 을 위해 발급됐다" 는 표시이고, audience 옵션은 "나는 auth-lab-web 용 토큰만 받는다" 는 선언입니다. iss 도 같은 방식으로 "우리 발급자가 만든 토큰만 받는다" 를 보장합니다.
exp 는 따로 옵션을 주지 않아도 검증됩니다. 만료 검사를 끄는 옵션이 있지만 쓰지 않습니다.
verifyAsync 는 검증에 실패하면 예외를 던집니다. 서명 불일치, 만료, aud 불일치 모두 예외이고, 예외의 종류와 메시지로 이유를 알 수 있습니다. 이 예외를 어떻게 다룰지는 다음 절의 가드에서 정합니다.
5.4 보호된 API — 가드와 토큰 모듈
jwt-auth.guard.ts
NestJS 에서 가드(guard) 는 요청이 컨트롤러 메서드에 닿기 전에 "이 요청을 통과시킬 것인가" 를 판단합니다. true 를 돌려주면 통과, 예외를 던지면 그 예외가 응답이 됩니다.
// api/src/auth/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, createParamDecorator } from '@nestjs/common';
import type { Request } from 'express';
import { AppError, ErrorCode } from '../common/errors';
import { AuthUser, TokenService } from './token.service';
type AuthedRequest = Request & { user?: AuthUser };
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly tokens: TokenService) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest<AuthedRequest>();
const [scheme, token] = (req.get('authorization') ?? '').split(' ');
if (scheme !== 'Bearer' || !token) {
throw new AppError(401, ErrorCode.ACCESS_TOKEN_INVALID, 'Bearer 토큰이 없다');
}
try {
req.user = await this.tokens.verifyAccessToken(token);
} catch {
throw new AppError(401, ErrorCode.ACCESS_TOKEN_INVALID, '액세스 토큰이 유효하지 않다');
}
return true;
}
}
export const CurrentUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext): AuthUser => ctx.switchToHttp().getRequest<AuthedRequest>().user!,
);
AuthedRequest. Express 의 Request 에 user 필드를 더한 타입입니다. 검증을 통과한 사용자 정보를 요청 객체에 붙여 두고, 뒤에서 컨트롤러가 꺼내 쓰게 하려는 것입니다.
헤더 파싱. Authorization 헤더를 공백으로 나눠 앞부분이 Bearer 인지, 뒷부분(토큰)이 있는지 확인합니다. 헤더가 없으면 빈 문자열로 바꿔 나누므로 scheme 도 token 도 비어 있게 되고, 첫 번째 if 에서 걸립니다. 이 파싱은 일부러 엄격합니다. bearer(소문자)나 공백 두 칸은 받지 않습니다. 이 API 를 부르는 쪽은 우리 프론트엔드와 curl 뿐이고, 둘 다 언제나 Bearer <토큰> 형식으로 보냅니다.
모든 실패를 같은 에러로. 토큰이 없을 때와 검증에 실패했을 때 모두 401 ACCESS_TOKEN_INVALID 입니다. 검증 실패의 이유(서명 불일치인지, 만료인지, aud 가 다른지)는 catch 에서 버립니다. 이유를 클라이언트에 알려 주지 않는 것은 두 가지 이유에서입니다. 공격자에게 "서명은 맞았는데 aud 만 틀렸다" 같은 힌트를 줄 필요가 없고, 프론트엔드 입장에서도 이유에 따라 할 일이 다르지 않습니다. 12장에서 프론트엔드는 이 에러를 받으면 리프레시를 한 번 시도하고, 그래도 안 되면 로그인 화면으로 보냅니다. 운영 중에 이유를 알아야 한다면 catch 에서 서버 로그로만 남기면 됩니다.
CurrentUser 데코레이터. createParamDecorator 로 만든 파라미터 데코레이터입니다. 컨트롤러 메서드에서 @CurrentUser() user: AuthUser 라고 쓰면, 가드가 붙여 둔 req.user 가 그 매개변수로 들어옵니다. 컨트롤러가 Express 의 Request 를 직접 받지 않아도 되니 코드가 깔끔해지고, 테스트하기도 쉬워집니다.
끝의 user! 는 "여기서는 반드시 값이 있다" 는 단언입니다. 이 데코레이터는 JwtAuthGuard 가 붙은 메서드에서만 쓴다는 약속이 전제입니다. 가드 없이 쓰면 undefined 가 그대로 들어오니 주의합니다.
token.module.ts
토큰 서비스와 가드를 한 모듈로 묶습니다.
// api/src/auth/token.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { JwtAuthGuard } from './jwt-auth.guard';
import { TokenService } from './token.service';
@Module({
imports: [JwtModule.register({})], // 비밀·옵션은 호출마다 AppConfig 에서 넘긴다
providers: [TokenService, JwtAuthGuard],
exports: [TokenService, JwtAuthGuard],
})
export class TokenModule {}
JwtModule.register({}) 는 JwtService 를 제공합니다. @nestjs/jwt 의 흔한 예제는 여기에 { secret: …, signOptions: { expiresIn: … } } 를 넣습니다. 이 책은 빈 객체를 넘기고, 비밀 키와 옵션을 TokenService 의 호출마다 AppConfig 에서 꺼내 넘깁니다. 이렇게 하면 서명과 검증에 쓰는 설정이 전부 TokenService 한 파일에 보입니다. 모듈 등록에 절반, 서비스 호출에 절반이 흩어져 있으면 "검증할 때 audience 를 확인하는가" 같은 질문에 답하려고 두 곳을 다 봐야 합니다.
왜 토큰 모듈을 따로 두는가 — 순환 의존
TokenService 와 JwtAuthGuard 를 AuthModule 에 그냥 넣으면 안 될까요? 다음 절에서 만들 UsersController 를 생각하면 답이 나옵니다.
UsersController는/users/me를 보호하려고JwtAuthGuard를 씁니다. 가드는TokenService가 필요합니다. 그러니UsersModule은 이것들을 가진 모듈을 import 해야 합니다.AuthService는 로그인할 때UsersRepository를 씁니다. 그러니AuthModule은UsersModule을 import 해야 합니다(4장에서 이미 그렇게 했습니다).
토큰 관련 코드가 AuthModule 에 있다면 UsersModule 은 AuthModule 을 import 하고, AuthModule 은 UsersModule 을 import 합니다. 두 모듈이 서로를 필요로 하는 순환 의존입니다.
토큰 코드가 AuthModule 에 있을 때 TokenModule 을 분리했을 때
UsersModule ──▶ AuthModule UsersModule ──▶ TokenModule
▲ │ ▲ ▲
└──────────────┘ │ │
(순환) AuthModule ────────┘
순환이 생기면 NestJS 는 부팅할 때 한쪽 모듈이 undefined 라는 에러를 내며 멈춥니다. 파일끼리도 서로를 import 하므로, 한쪽 파일이 로드되는 도중에 다른 쪽이 그것을 참조하게 되어 값이 아직 없는 상태가 되기 때문입니다. NestJS 는 이것을 풀기 위한 forwardRef() 를 제공하지만, 이것은 순환을 허용하는 장치이지 없애는 장치가 아닙니다. 의존 관계가 얽힌 채로 남고, 다른 곳에서 비슷한 문제가 또 생깁니다.
더 나은 해결책은 두 모듈이 공통으로 쓰는 부분을 떼어 내는 것입니다. 토큰을 서명·검증하는 코드는 사용자 데이터에 의존하지 않습니다. 비밀 키(AppConfig)만 있으면 됩니다. 그래서 TokenModule 로 분리하고, UsersModule 과 AuthModule 이 각자 이것을 import 합니다. 의존 방향이 한쪽으로만 흐르게 됩니다. 파일 위치는 auth/ 폴더 안에 두었습니다. 토큰은 인증의 일부라는 점은 변함없기 때문입니다.
exports 에 JwtAuthGuard 까지 넣은 것은 다른 모듈이 가드를 주입받아 쓸 수도 있게 하려는 것입니다. @UseGuards(JwtAuthGuard) 로 쓸 때는 NestJS 가 컨트롤러가 속한 모듈에서 가드를 만들면서 TokenService 를 찾으므로, TokenService 를 내보내는 것이 필수입니다.
5.5 /users/me — 첫 번째 보호된 API
토큰을 쓰는 쪽을 만듭니다. 로그인한 사용자가 자기 정보를 보고, 닉네임을 바꾸는 API 입니다.
// api/src/users/users.controller.ts
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { ApiBadRequestResponse, ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { CurrentUser, JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthUser } from '../auth/token.service';
import { ErrorResponseDto } from '../common/error-response.dto';
import { AppError, ErrorCode } from '../common/errors';
import { UpdateNicknameDto, UserDto, toUserDto } from './dto';
import { UsersRepository } from './users.repository';
@ApiTags('users')
@ApiBearerAuth('access-token')
@ApiUnauthorizedResponse({ type: ErrorResponseDto, description: 'ACCESS_TOKEN_INVALID' })
@UseGuards(JwtAuthGuard)
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersRepository) {}
@Get('me')
@ApiOperation({ summary: '내 정보' })
@ApiOkResponse({ type: UserDto })
async me(@CurrentUser() user: AuthUser): Promise<UserDto> {
const row = await this.users.findById(user.userId);
if (!row) throw new AppError(401, ErrorCode.ACCESS_TOKEN_INVALID, '사용자가 없다');
return toUserDto(row);
}
@Patch('me')
@ApiOperation({ summary: '닉네임 변경' })
@ApiBadRequestResponse({ type: ErrorResponseDto, description: 'VALIDATION_FAILED' })
@ApiOkResponse({ type: UserDto })
async updateMe(@CurrentUser() user: AuthUser, @Body() dto: UpdateNicknameDto): Promise<UserDto> {
const row = await this.users.updateNickname(user.userId, dto.nickname);
if (!row) throw new AppError(401, ErrorCode.ACCESS_TOKEN_INVALID, '사용자가 없다');
return toUserDto(row);
}
}
클래스에 붙은 데코레이터. 네 개의 데코레이터가 메서드가 아니라 클래스에 붙어 있습니다. 이 컨트롤러의 모든 엔드포인트에 적용된다는 뜻입니다. @UseGuards(JwtAuthGuard) 로 모든 엔드포인트가 보호되고, @ApiBearerAuth('access-token') 으로 Swagger 에서 모든 엔드포인트에 자물쇠가 붙습니다. 'access-token' 은 3장의 openapi.ts 에서 등록한 보안 방식의 이름입니다. @ApiUnauthorizedResponse 도 클래스에 한 번 붙여 모든 엔드포인트의 401 을 문서화합니다. 새 엔드포인트를 추가할 때 가드를 빠뜨리는 실수를 구조적으로 막습니다.
me. @CurrentUser() 로 받은 userId 로 DB 에서 사용자를 조회합니다. 토큰은 유효한데 사용자가 없는 경우가 있을 수 있습니다. 토큰을 받은 뒤 계정이 삭제된 경우입니다. 이때는 401 ACCESS_TOKEN_INVALID 로 답합니다. 404 로 답하면 프론트엔드가 "사용자가 없다" 는 새로운 상황을 처리해야 하지만, 401 이면 이미 처리하는 "토큰이 무효하다" 흐름을 그대로 탑니다. 결과적으로 사용자는 로그인 화면으로 가게 되고, 그것이 올바른 결과입니다.
updateMe. 닉네임을 바꾸는 PATCH /users/me 입니다. 4장에서 만들어 둔 UpdateNicknameDto 와 updateNickname 을 씁니다. 이 엔드포인트는 8장에서 중요한 역할을 합니다. 8장은 "액세스 토큰을 쿠키에 넣었다면 이 닉네임 변경이 CSRF 공격에 어떻게 뚫리는가" 를 실습하는데, 지금 이 API 는 Authorization 헤더로만 인증하므로 그 공격이 통하지 않습니다. 다른 사이트는 우리 사용자의 브라우저에 Authorization 헤더를 붙이게 만들 수 없기 때문입니다.
💡 JwtAuthGuard 는 DB 를 보지 않습니다. 서명과 클레임만 확인합니다. 이것이 JWT 를 쓰는 이유이자 한계입니다. 7장에서 로그아웃으로 세션을 끊어도, 이미 발급된 액세스 토큰은 exp 까지 이 가드를 통과합니다. 그래서 액세스 토큰의 수명을 10분으로 짧게 잡았습니다(2장).
users.module.ts
UsersModule 에 토큰 모듈과 컨트롤러를 더합니다. 부록 C 의 최종 모습입니다.
// api/src/users/users.module.ts
import { Module } from '@nestjs/common';
import { TokenModule } from '../auth/token.module';
import { UsersController } from './users.controller';
import { UsersRepository } from './users.repository';
@Module({
imports: [TokenModule],
controllers: [UsersController],
providers: [UsersRepository],
exports: [UsersRepository],
})
export class UsersModule {}
5.6 로그인 서비스 — AuthService.login
이제 로그인 로직을 씁니다. AuthService 의 생성자에 TokenService 와 AppConfig 가 추가됩니다.
// api/src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { AppError, ErrorCode } from '../common/errors';
import { AppConfig } from '../config/app-config';
import { UserDto, toUserDto } from '../users/dto';
import { UsersRepository } from '../users/users.repository';
import { LoginDto, SignupDto, TokenResponseDto } from './dto';
import { PasswordService } from './password.service';
import { TokenService } from './token.service';
@Injectable()
export class AuthService {
constructor(
private readonly users: UsersRepository,
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
private readonly config: AppConfig,
) {}
// … signup 은 4장과 같다
async login(dto: LoginDto): Promise<TokenResponseDto> {
const user = await this.users.findByEmail(dto.email);
const ok = user
? await this.passwords.verify(user.password_hash, dto.password)
: await this.passwords.burn(dto.password);
if (!user || !ok) {
throw new AppError(401, ErrorCode.INVALID_CREDENTIALS, '이메일 또는 비밀번호가 맞지 않다');
}
// 임시 세션 id. 6장에서 리프레시 토큰의 family id(= 세션 id)로 바뀐다.
const sessionId = randomUUID();
const accessToken = await this.tokens.signAccessToken(user.id, sessionId);
return { accessToken, tokenType: 'Bearer', expiresIn: this.config.accessTokenTtlSec, user: toUserDto(user) };
}
}
두 갈래를 한 번에 합친다
login 의 앞부분은 5.1 절의 해법을 그대로 옮긴 것입니다. 사용자가 있으면 진짜 해시로, 없으면 더미 해시로 검증합니다. 어느 쪽이든 argon2 검증을 정확히 한 번 합니다. 그 결과를 ok 에 담고, 판정은 if (!user || !ok) 한 곳에서 합니다.
두 실패 경우가 같은 throw 문을 지나간다는 점이 중요합니다. 에러 코드와 메시지는 물론이고, 에러가 만들어지는 경로까지 같습니다. 코드를 고치다가 한쪽 메시지만 바뀌는 일이 생길 수 없습니다.
TypeScript 도 이 구조를 이해합니다. if (!user || !ok) 를 통과한 뒤에는 user 가 null 이 아니라는 것을 알기 때문에, 아래에서 user.id 를 ! 없이 쓸 수 있습니다.
임시 세션 id
토큰의 sid 클레임에는 세션 id 가 들어가야 합니다. 이 책의 설계에서 세션 id 는 리프레시 토큰 family 의 id 입니다. 로그인 한 번이 family 하나를 열고, 그 family 의 id 가 이 로그인에서 나온 모든 액세스 토큰의 sid 가 됩니다. 7장의 세션 목록에서 "이 기기" 를 표시하거나, 특정 세션을 끊을 때 이 연결을 씁니다.
그런데 이 장에는 아직 리프레시 토큰이 없습니다. family 도 없습니다. 그래서 randomUUID() 로 임시 값을 넣어 자리만 채웁니다. 토큰 형식은 최종 모습과 같게 유지하면서, 세션과의 연결은 6장으로 미루는 것입니다. 6장에서 이 줄은 리프레시 토큰을 DB 에 저장하고 받은 family id 로 바뀝니다. 그때 randomUUID import 도 사라집니다.
응답 만들기
반환값은 TokenResponseDto 모양의 객체입니다. expiresIn 은 AppConfig 의 값을 그대로 씁니다. 서명에 쓴 값과 같은 출처에서 오니 둘이 어긋날 일이 없습니다. user 는 4장의 toUserDto 를 거쳐 password_hash 가 빠진 모양으로 나갑니다.
5.7 로그인 시도 제한
마지막으로 로그인 엔드포인트를 만들고, 비밀번호 대입을 늦추는 장치를 붙입니다.
비밀번호 대입 공격에는 두 가지 모양이 있습니다. 한 계정에 비밀번호 후보를 계속 넣어 보는 무차별 대입과, 다른 곳에서 유출된 이메일·비밀번호 쌍을 그대로 넣어 보는 크리덴셜 스터핑입니다. argon2 가 느리긴 하지만, 그것은 DB 가 유출됐을 때 오프라인 공격을 늦추는 장치입니다. 온라인으로 로그인 API 를 두드리는 공격은 요청 수 자체를 제한해야 합니다.
login-throttler.guard.ts
@nestjs/throttler 의 ThrottlerGuard 가 요청 횟수를 세고 제한을 넘으면 429 를 던집니다. 기본 응답은 이 책의 에러 모양이 아니므로, 상속해서 던지는 예외만 바꿉니다.
// api/src/auth/login-throttler.guard.ts
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { AppError, ErrorCode } from '../common/errors';
/** 제한에 걸렸을 때도 { statusCode, code, message } 모양으로 응답한다. */
@Injectable()
export class LoginThrottlerGuard extends ThrottlerGuard {
protected override async throwThrottlingException(): Promise<void> {
throw new AppError(429, ErrorCode.TOO_MANY_REQUESTS, '로그인 시도가 너무 많다. 잠시 뒤 다시 시도하라');
}
}
throwThrottlingException 은 ThrottlerGuard 가 제한을 넘은 요청을 발견했을 때 부르는 메서드입니다. 원래 메서드는 몇 가지 인자(요청 문맥, 제한 정보)를 받지만, 우리는 쓰지 않으므로 매개변수 없이 재정의했습니다. TypeScript 에서 매개변수가 더 적은 함수로 재정의하는 것은 허용됩니다. override 키워드는 "부모에 이 이름의 메서드가 있어야 한다" 는 표시라서, 라이브러리가 메서드 이름을 바꾸면 컴파일 에러로 알 수 있습니다. @nestjs/throttler 를 올렸는데 이 부분이 깨진다면 해당 버전의 공식 문서에서 메서드 이름과 시그니처를 확인하세요.
auth.controller.ts — login
4장의 컨트롤러에 로그인 엔드포인트를 더합니다.
// api/src/auth/auth.controller.ts
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiConflictResponse,
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiTooManyRequestsResponse,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { ErrorResponseDto } from '../common/error-response.dto';
import { UserDto } from '../users/dto';
import { AuthService } from './auth.service';
import { LoginDto, SignupDto, TokenResponseDto } from './dto';
import { LoginThrottlerGuard } from './login-throttler.guard';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
// … signup 은 4장과 같다
@Post('login')
@HttpCode(200)
@UseGuards(LoginThrottlerGuard)
@ApiOperation({ summary: '로그인' })
@ApiOkResponse({ type: TokenResponseDto })
@ApiUnauthorizedResponse({ type: ErrorResponseDto, description: 'INVALID_CREDENTIALS' })
@ApiBadRequestResponse({ type: ErrorResponseDto, description: 'VALIDATION_FAILED' })
@ApiTooManyRequestsResponse({ type: ErrorResponseDto, description: 'TOO_MANY_REQUESTS' })
login(@Body() dto: LoginDto): Promise<TokenResponseDto> {
return this.auth.login(dto);
}
}
@HttpCode(200). POST 의 기본 상태는 201 Created 입니다. 로그인은 새 자원을 만들지 않으므로 200 으로 바꿉니다. 6장에서 리프레시 토큰이 DB 에 행을 하나 만들긴 하지만, 클라이언트 입장에서 로그인은 "자원 생성" 이 아니라 "인증" 입니다.
@UseGuards(LoginThrottlerGuard). 로그인 엔드포인트에만 시도 제한을 겁니다. 성공한 로그인도 횟수에 포함됩니다. 가드는 요청이 처리되기 전에 돌기 때문에, 그 요청이 성공할지 실패할지 모르는 시점에 횟수를 셉니다.
Swagger 데코레이터. 4장의 규칙대로 성공 응답(TokenResponseDto)과 두 에러 응답의 code 를 적었습니다.
💡 부록 C 의 login 과 비교하면 아직 여러 가지가 빠져 있습니다. 6장에서 리프레시 토큰을 쿠키로 내려보내려고 @Req()·@Res() 매개변수와 Cache-Control: no-store 헤더, @ApiOperation 의 설명(description)을 추가하고, 9장에서 CSRF 방어를 위해 CsrfGuard 와 관련 Swagger 데코레이터를 추가합니다.
auth.module.ts
// api/src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { LoginThrottlerGuard } from './login-throttler.guard';
import { PasswordService } from './password.service';
import { TokenModule } from './token.module';
@Module({
imports: [TokenModule, UsersModule, ThrottlerModule.forRoot([{ ttl: 60_000, limit: 5 }])],
controllers: [AuthController],
providers: [AuthService, PasswordService, LoginThrottlerGuard],
})
export class AuthModule {}
TokenModule 은 AuthService 가 토큰을 발급하려고 import 합니다.
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 5 }]) 는 "60초(60,000 밀리초) 동안 5번" 이라는 제한을 정의합니다. ttl 의 단위가 밀리초라는 점에 주의하세요. @nestjs/throttler 는 버전 5 부터 초에서 밀리초로 단위를 바꿨습니다. 오래된 예제의 ttl: 60 을 그대로 옮기면 60밀리초가 되어 제한이 사실상 없어집니다.
이 모듈을 앱 전체(AppModule)가 아니라 AuthModule 에 등록하고, 가드도 전역으로 걸지 않고 로그인 엔드포인트에만 붙였습니다. 이 책에서 시도 제한이 필요한 곳은 비밀번호를 검증하는 로그인뿐입니다. 전역으로 걸면 12장에서 프론트엔드가 여러 탭에서 API 를 부를 때 정상 요청까지 제한에 걸립니다.
이 제한이 막는 것과 못 막는 것
ThrottlerGuard 는 기본적으로 요청한 IP 와 엔드포인트를 묶어 횟수를 셉니다. 같은 IP 에서 1분에 5번까지 로그인을 시도할 수 있고, 6번째부터는 429 를 받습니다. 한 IP 에서 하루에 시도할 수 있는 횟수가 7,200번으로 줄어드니, 한 곳에서 흔한 비밀번호 목록을 대입하는 공격은 크게 느려집니다.
한계도 분명합니다.
- 여러 IP 를 쓰는 공격은 막지 못합니다. 크리덴셜 스터핑은 보통 수많은 IP 에서 들어옵니다. 계정 단위로 실패 횟수를 세서 잠그는 방법이 있지만, 그러면 공격자가 남의 계정을 일부러 잠가 버리는 서비스 거부가 가능해집니다. 이 책은 IP 단위 제한까지만 다룹니다.
- 카운터가 메모리에 있습니다. 서버를 재시작하면 초기화되고, 서버를 여러 대 띄우면 각자 따로 셉니다. 운영에서는 Redis 같은 공유 저장소를 쓰는 저장소 구현으로 바꿔야 합니다(14장).
- 리버스 프록시 뒤에서는 모든 요청의 IP 가 프록시 주소로 보일 수 있습니다. 그러면 모든 사용자가 하나의 카운터를 나눠 쓰게 됩니다. 14장의
trust proxy설정에서 다룹니다.
5.8 로그인하고 보호된 API 를 부른다
이제 실행해 봅니다. 서버가 start:dev 로 떠 있다면 저장할 때 이미 재시작됐을 것입니다.
⚠️ 이제부터 로그인은 1분에 5번까지만 됩니다. 이 장의 실습을 따라가다 429 TOO_MANY_REQUESTS 를 받으면 1분 쉬었다가 다시 하세요. 서버를 재시작해도 카운터가 초기화됩니다.
curl 로 로그인
4장에서 가입한 kim@example.com 으로 로그인합니다.
$ API=http://localhost:4000
$ curl -s -X POST "$API/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"kim@example.com","password":"correct horse battery"}' | jq
예시 출력:
{
"accessToken": "eyJhbGciOi…",
"tokenType": "Bearer",
"expiresIn": 600,
"user": {
"id": "5f1c2a8e-…",
"email": "kim@example.com",
"nickname": "김개발",
"createdAt": "2026-09-24T05:18:44.102Z"
}
}
토큰을 셸 변수에 담아 둡니다. 이 장의 나머지 실습에서 계속 씁니다.
$ TOKEN=$(curl -s -X POST "$API/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"kim@example.com","password":"correct horse battery"}' | jq -r .accessToken)
토큰 안을 들여다보기
2장에서 JWT 를 손으로 해부했던 것처럼, 방금 받은 토큰의 헤더와 페이로드를 풀어 봅니다. 셸 함수를 하나 만들어 둡니다.
$ jwtd() { cut -d. -f1-2 <<<"$1" | tr . '\n' | node -e "require('node:readline').createInterface({input:process.stdin}).on('line', l => console.log(Buffer.from(l, 'base64url').toString()))"; }
$ jwtd "$TOKEN"
예시 출력:
{"alg":"HS256","typ":"JWT"}
{"sid":"c7d0e0b4-…","iat":1790227124,"exp":1790227724,"aud":"auth-lab-web","iss":"auth-lab-api","sub":"5f1c2a8e-…","jti":"9a41b3f2-…"}
exp - iat 가 600 인지, sub 가 로그인 응답의 user.id 와 같은지, aud·iss 가 .env 의 값인지 확인합니다. 한 번 더 로그인하면 sid 와 jti 가 매번 다른 것도 볼 수 있습니다.
보호된 API 부르기
토큰을 실어서 /users/me 를 부릅니다.
$ curl -s -w '\n%{http_code}\n' "$API/users/me" -H "Authorization: Bearer $TOKEN"
예시 출력:
{"id":"5f1c2a8e-…","email":"kim@example.com","nickname":"김개발","createdAt":"2026-09-24T05:18:44.102Z"}
200
토큰 없이 부르면 401 입니다.
$ curl -s -w '\n%{http_code}\n' "$API/users/me"
예시 출력:
{"statusCode":401,"code":"ACCESS_TOKEN_INVALID","message":"Bearer 토큰이 없다"}
401
닉네임도 바꿔 봅니다.
$ curl -s -w '\n%{http_code}\n' -X PATCH "$API/users/me" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"nickname":" 김토큰 "}'
예시 출력:
{"id":"5f1c2a8e-…","email":"kim@example.com","nickname":"김토큰","createdAt":"2026-09-24T05:18:44.102Z"}
200
앞뒤 공백이 지워져 저장된 것을 확인합니다. UpdateNicknameDto 의 @Transform(trim) 이 한 일입니다.
틀린 비밀번호와 없는 이메일
두 실패가 같은 응답인지 봅니다.
$ curl -s -w '\n%{http_code}\n' -X POST "$API/auth/login" \
-H 'Content-Type: application/json' -d '{"email":"kim@example.com","password":"wrong password"}'
$ curl -s -w '\n%{http_code}\n' -X POST "$API/auth/login" \
-H 'Content-Type: application/json' -d '{"email":"nobody@example.com","password":"wrong password"}'
예시 출력:
{"statusCode":401,"code":"INVALID_CREDENTIALS","message":"이메일 또는 비밀번호가 맞지 않다"}
401
{"statusCode":401,"code":"INVALID_CREDENTIALS","message":"이메일 또는 비밀번호가 맞지 않다"}
401
본문이 글자 하나까지 같습니다. 응답 시간은 이 장 끝의 "확인하기" 에서 잽니다. 여기까지 로그인을 몇 번 했는지 세어 두세요. 5번을 넘었다면 1분 쉽니다.
5.9 공격 토큰을 만들어 보낸다
이 장의 가장 중요한 실습입니다. 5.3 절에서 검증 옵션을 고정한 이유를 말로만 설명했는데, 이제 실제로 공격 토큰을 만들어 보내 봅니다.
위조 도구
토큰을 위조하는 짧은 스크립트를 씁니다. 이 파일은 실습용 임시 파일이라 api/ 프로젝트 안에 넣지 않고 /tmp 에 만듭니다. Node.js 에 들어 있는 node:crypto 만 씁니다.
// /tmp/forge-jwt.mjs — 실습용 임시 스크립트. 프로젝트에 넣지 않는다.
import { createHmac } from 'node:crypto';
const [, , token, kind] = process.argv;
const secret = process.env.SECRET;
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const [h, p, s] = token.split('.');
const header = JSON.parse(Buffer.from(h, 'base64url').toString());
const payload = JSON.parse(Buffer.from(p, 'base64url').toString());
const sign = (hd, pl) => {
const input = `${b64(hd)}.${b64(pl)}`;
return `${input}.${createHmac('sha256', secret).update(input).digest('base64url')}`;
};
const now = Math.floor(Date.now() / 1000);
const forged = {
none: () => `${b64({ alg: 'none', typ: 'JWT' })}.${b64(payload)}.`,
tamper: () => `${h}.${b64({ ...payload, sub: '00000000-0000-0000-0000-000000000000' })}.${s}`,
aud: () => sign(header, { ...payload, aud: 'another-service' }),
iss: () => sign(header, { ...payload, iss: 'someone-else' }),
expired: () => sign(header, { ...payload, iat: now - 120, exp: now - 60 }),
resign: () => sign(header, payload),
};
console.log(forged[kind]());
진짜 토큰 하나를 받아서 여섯 가지로 변형합니다.
| 종류 | 만드는 법 | 무엇을 흉내 내는가 |
|---|---|---|
none | 헤더를 {"alg":"none"} 으로, 서명을 비움 | 5.3 절의 alg: none 공격 |
tamper | 페이로드의 sub 를 다른 사용자로 바꾸고 원래 서명을 그대로 붙임 | 비밀 키 없이 내용만 바꾸기 |
aud | 올바른 비밀 키로 서명하되 aud 를 another-service 로 | 같은 키를 쓰는 다른 서비스용 토큰 |
iss | 올바른 비밀 키로 서명하되 iss 를 someone-else 로 | 다른 발급자의 토큰 |
expired | 올바른 비밀 키로 서명하되 exp 를 1분 전으로 | 만료된 토큰 |
resign | 아무것도 바꾸지 않고 다시 서명 | 대조군. 스크립트가 맞게 서명하는지 확인 |
aud, iss, expired 는 공격자가 비밀 키를 알고 있다고 가정한 것입니다. 현실에서 공격자가 우리 비밀 키를 알면 이미 끝난 게임 아닌가 싶겠지만, 이 세 가지는 "키는 맞는데 다른 용도·다른 곳·다른 시간의 토큰" 을 흉내 냅니다. 앞에서 말한 대로 같은 키를 쓰는 다른 서비스나 다른 환경에서 정당하게 발급된 토큰이 딱 이런 모양입니다.
스크립트가 쓸 비밀 키를 .env 에서 읽어 옵니다. api/ 안에서 실행합니다.
$ export SECRET=$(grep '^JWT_ACCESS_SECRET=' .env | cut -d= -f2-)
먼저 대조군부터 확인합니다. 다시 서명한 토큰은 원래 토큰과 같아야 하고, 200 이 나와야 합니다.
$ [ "$(node /tmp/forge-jwt.mjs "$TOKEN" resign)" = "$TOKEN" ] && echo same
same
same 이 나오면 스크립트의 서명 방식이 서버와 같다는 뜻입니다. 이제 공격 토큰을 하나씩 보냅니다.
$ for kind in none tamper aud iss expired; do
forged=$(node /tmp/forge-jwt.mjs "$TOKEN" $kind)
printf '%-8s ' $kind
curl -s -o /dev/null -w '%{http_code}\n' "$API/users/me" -H "Authorization: Bearer $forged"
done
예시 출력:
none 401
tamper 401
aud 401
iss 401
expired 401
다섯 가지 모두 401 입니다. 응답 본문은 모두 ACCESS_TOKEN_INVALID 로 같습니다. 5.4 절에서 말했듯 가드가 이유를 숨기기 때문입니다.
검증 옵션을 하나 빼 보면
막혔다는 것만으로는 무엇이 막았는지 알 수 없습니다. audience 옵션이 정말 일을 하고 있는지 확인하려면 잠시 빼 봐야 합니다. token.service.ts 의 verifyAccessToken 에서 audience 줄을 주석 처리하고 저장합니다.
// api/src/auth/token.service.ts — 실험용. 확인 후 반드시 되돌린다
const payload = await this.jwt.verifyAsync<{ sub: string; sid: string }>(token, {
secret: this.config.jwtAccessSecret,
algorithms: ['HS256'], // 토큰 헤더의 alg 를 믿지 않는다
issuer: this.config.jwtIssuer,
// audience: this.config.jwtAudience,
});
서버가 재시작되면 aud 토큰을 다시 보냅니다.
$ curl -s -w '\n%{http_code}\n' "$API/users/me" \
-H "Authorization: Bearer $(node /tmp/forge-jwt.mjs "$TOKEN" aud)"
예시 출력:
{"id":"5f1c2a8e-…","email":"kim@example.com","nickname":"김토큰","createdAt":"2026-09-24T05:18:44.102Z"}
200
통과했습니다. another-service 를 위해 발급된 토큰이 우리 API 를 열었습니다. 서명이 맞고 만료되지 않았으니, aud 를 보지 않는 검증기에게는 아무 문제가 없는 토큰입니다. 같은 실험을 issuer 로도 해 볼 수 있습니다.
확인이 끝나면 반드시 주석을 풀어 원래대로 되돌립니다. 다시 aud 토큰을 보내 401 이 나오는지 확인하세요.
algorithms 줄을 빼고 none 토큰을 보내 보는 것도 좋은 실험입니다. 여러분이 설치한 버전의 라이브러리는 algorithms 가 없어도 none 토큰을 거부할 가능성이 큽니다. 요즘 라이브러리는 비밀 키가 주어지면 서명 없는 토큰을 받지 않는 쪽으로 기본값이 바뀌었기 때문입니다. 그렇다면 이 옵션은 필요 없는 것일까요? 이 책의 답은 "필요하다" 입니다. 그 안전은 라이브러리의 현재 기본값이 주는 것이고, 우리 코드가 보장하는 것이 아닙니다. 기본값은 버전에 따라, 키의 종류에 따라 달라질 수 있습니다. 옵션 한 줄로 그 의존을 끊을 수 있다면 끊는 편이 낫습니다.
💡 검증에 실패한 이유를 직접 보고 싶다면, JwtAuthGuard 의 catch 를 잠시 catch (e) { console.log((e as Error).message); throw new AppError(…) } 처럼 바꿔 서버 로그로 볼 수 있습니다. invalid signature, jwt audience invalid, jwt expired 같은 메시지가 찍힐 것입니다(정확한 문구는 라이브러리 버전에 따라 다릅니다). 확인한 뒤에는 되돌립니다. 응답에는 이 이유를 절대 싣지 않습니다.
5.10 Swagger UI 로 로그인하고 인가하기
같은 흐름을 Swagger UI 에서도 해 봅니다. 프론트엔드 개발자가 API 를 처음 만져 볼 때 가장 먼저 하게 될 일입니다.
http://localhost:4000/docs를 엽니다.auth묶음에POST /auth/login이,users묶음에GET /users/me와PATCH /users/me가 새로 보입니다.users의 두 엔드포인트 옆에는 자물쇠 아이콘이 있습니다.POST /auth/login을 펼쳐 Try it out → 예시 값의 이메일·비밀번호를 여러분이 가입한 값으로 고치고 Execute 를 누릅니다. 응답 본문에서accessToken값을 따옴표 없이 복사합니다.- 오른쪽 위 Authorize 를 누르고,
access-token (http, Bearer)칸에 복사한 토큰을 붙여 넣은 뒤 Authorize → Close 를 누릅니다.Bearer접두어는 Swagger UI 가 붙여 주므로 토큰만 넣습니다. GET /users/me를 펼쳐 Try it out → Execute. 200 과 내 정보가 옵니다. Curl 칸에 Swagger UI 가 보낸 요청이 보이는데,-H 'Authorization: Bearer eyJhbGciOi…'가 붙어 있는 것을 확인할 수 있습니다.- 다시 Authorize 를 눌러 Logout 한 뒤 같은 요청을 실행해 보세요. 401 과
ACCESS_TOKEN_INVALID가 옵니다.
3장에서 persistAuthorization: true 를 켜 두었으므로, 페이지를 새로고침해도 입력한 토큰이 남아 있습니다. 편리하지만, 토큰이 브라우저 저장소에 남는다는 뜻이기도 합니다. 운영에서 Swagger 를 끄는 이유 중 하나입니다.
Swagger UI 에서 POST /auth/login 의 Responses 를 보면 200·401·429 세 가지가 문서화되어 있고, 401 과 429 의 설명에 각각 INVALID_CREDENTIALS, TOO_MANY_REQUESTS 가 적혀 있습니다. 4장에서 정한 문서화 규칙이 이 장에서도 지켜졌는지 이렇게 확인합니다.
5.11 만료를 눈으로 보기
액세스 토큰은 10분 뒤에 만료됩니다. 10분을 기다리기는 지루하니, 수명을 잠시 30초로 줄여 봅니다. 서버를 멈추고 .env 를 고칩니다.
# api/.env — 실험용. 확인 후 600 으로 되돌린다
ACCESS_TOKEN_TTL_SEC=30
start:dev 는 src/ 의 변경만 감시하고 .env 는 감시하지 않으므로, 직접 재시작해야 합니다.
$ pnpm start:dev
다시 로그인해 새 토큰을 받고, 응답의 expiresIn 과 토큰의 exp - iat 가 모두 30 인지 봅니다.
$ TOKEN=$(curl -s -X POST "$API/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"kim@example.com","password":"correct horse battery"}' | jq -r .accessToken)
$ jwtd "$TOKEN" | tail -n 1 | jq '.exp - .iat'
30
곧바로 /users/me 를 부르면 200, 30초가 지난 뒤 부르면 401 입니다. 5초 간격으로 부르며 경계를 지켜봅니다.
$ for i in $(seq 1 8); do
printf '%2ss ' $((i * 5))
curl -s -o /dev/null -w '%{http_code}\n' "$API/users/me" -H "Authorization: Bearer $TOKEN"
sleep 5
done
예시 출력:
5s 200
10s 200
15s 200
20s 200
25s 200
30s 401
35s 401
40s 401
첫 줄이 로그인 직후가 아니므로 경계가 한 칸 앞뒤로 어긋날 수 있습니다. 중요한 것은 토큰이 서버의 기억 없이 스스로 만료된다는 점입니다. 서버는 이 토큰을 어디에도 저장하지 않았고, 폐기하는 코드도 없습니다. 토큰 안의 exp 가 지났을 뿐입니다.
이 실험은 이 장의 설계가 가진 불편함도 보여 줍니다. 만료된 뒤 사용자가 할 수 있는 일은 다시 로그인하는 것뿐입니다. 10분마다 비밀번호를 다시 입력하게 할 수는 없습니다. 그렇다고 수명을 하루로 늘리면, 토큰이 새었을 때 하루 동안 막을 방법이 없습니다. 이 두 요구를 함께 만족시키는 것이 6장의 리프레시 토큰입니다.
확인이 끝나면 .env 를 ACCESS_TOKEN_TTL_SEC=600 으로 되돌리고 서버를 재시작합니다.
확인하기
로그인 제한 때문에 아래 표를 한 번에 돌리면 429 가 섞입니다. 로그인이 들어가는 항목(1, 4, 5, 9) 사이에는 1분씩 쉬거나 서버를 재시작하세요.
| # | 명령 | 기대 결과 |
|---|---|---|
| 1 | 올바른 이메일·비밀번호로 POST /auth/login | 200, tokenType 이 Bearer, expiresIn 이 600, user 에 password_hash 없음 |
| 2 | GET /users/me + Authorization: Bearer $TOKEN | 200 |
| 3 | GET /users/me 헤더 없이 | 401, ACCESS_TOKEN_INVALID |
| 4 | 틀린 비밀번호 / 없는 이메일로 로그인 | 둘 다 401, 본문이 글자까지 같음 |
| 5 | 4번의 두 요청을 각각 curl -s -o /dev/null -w '%{time_total}\n' 로 세 번씩 (첫 요청은 제외) | 두 경우의 시간이 수 ms 안에서 비슷함 |
| 6 | none·tamper·aud·iss·expired 위조 토큰으로 /users/me | 전부 401 |
| 7 | audience 옵션을 주석 처리하고 aud 토큰 | 200 (실험 후 되돌리고 다시 401 확인) |
| 8 | ACCESS_TOKEN_TTL_SEC=30 으로 로그인 후 31초 뒤 /users/me | 401 |
| 9 | 1분 안에 로그인 6번 (성공·실패 섞여도 됨) | 6번째가 429, code 가 TOO_MANY_REQUESTS |
| 10 | curl -s $API/docs-json | jq '.paths | keys' | /auth/login, /auth/signup, /users/me |
5번의 응답 시간 측정은 이렇게 합니다.
$ for body in '{"email":"kim@example.com","password":"wrong password"}' \
'{"email":"nobody@example.com","password":"wrong password"}'; do
curl -s -o /dev/null -w '%{time_total}\n' -X POST "$API/auth/login" \
-H 'Content-Type: application/json' -d "$body"
done
예시 출력:
0.048213
0.049870
두 값이 비슷하면 됩니다. 비교해 보고 싶다면 AuthService.login 에서 burn 대신 false 를 넣은 채로 같은 측정을 해 보세요. 없는 이메일 쪽이 눈에 띄게 빨라질 것입니다. 확인 후에는 되돌립니다.
자주 하는 실수
서버가 뜨면서 Nest can't resolve dependencies of the JwtAuthGuard (?) 가 난다. UsersModule 의 imports 에 TokenModule 이 없거나, TokenModule 의 exports 에 TokenService 가 없습니다. @UseGuards(JwtAuthGuard) 는 컨트롤러가 속한 모듈 안에서 가드를 만들기 때문에, 그 모듈에서 TokenService 가 보여야 합니다.
서버가 뜨면서 모듈 하나가 undefined 라는 에러가 난다. 모듈끼리 서로를 import 하는 순환이 생겼습니다. 토큰 관련 코드를 AuthModule 에 두고 UsersModule 이 AuthModule 을 import 한 경우가 대표적입니다. TokenModule 로 분리합니다.
로그인이 201 을 준다. @HttpCode(200) 이 빠졌습니다.
로그인 제한이 전혀 걸리지 않는다. ttl 을 초 단위(60)로 적었습니다. 밀리초입니다(60_000). 또는 @UseGuards(LoginThrottlerGuard) 를 빠뜨렸습니다.
429 응답이 { "statusCode": 429, "message": "ThrottlerException: Too Many Requests" } 모양이다. 가드로 ThrottlerGuard 를 그대로 쓰고 있습니다. LoginThrottlerGuard 로 바꿉니다.
방금 로그인했는데 곧바로 /users/me 가 401 이다. 몇 가지를 차례로 봅니다. Authorization 헤더에 Bearer 접두어를 빠뜨렸는지, 셸 변수 $TOKEN 이 비어 있거나 null 인지(로그인이 429 로 실패했을 수 있습니다), 5.11 절의 실험 후 ACCESS_TOKEN_TTL_SEC 를 되돌리지 않았는지, 5.9 절의 실험 후 audience 주석을 풀면서 오타를 냈는지 확인합니다.
Swagger UI 의 Authorize 에 Bearer eyJ… 를 통째로 넣었다. Swagger UI 가 Bearer 를 한 번 더 붙여 Bearer Bearer eyJ… 가 됩니다. 토큰만 넣습니다.
.env 를 고쳤는데 반영되지 않는다. start:dev 는 .env 변경을 감시하지 않습니다. 서버를 직접 재시작합니다.
없는 이메일로 로그인할 때 응답이 훨씬 빠르다. burn 을 부르지 않고 바로 throw 하고 있습니다. 서버를 띄운 뒤 첫 번째 요청만 느린 것은 정상입니다(더미 해시를 만드는 시간).
정리
- 로그인 실패는 이메일이 없든 비밀번호가 틀리든 같은
INVALID_CREDENTIALS로 답합니다. 응답 시간까지 맞추려고 없는 이메일일 때도 더미 해시로 argon2 검증을 한 번 돌립니다(burn). 더미 해시는 같은 파라미터로 한 번만,Promise째로 만들어 둡니다. - 액세스 토큰은 HS256 JWT 이고
sub·sid·jti·iss·aud·iat·exp를 담습니다. 비밀 키와 옵션은 모두TokenService에서AppConfig를 보고 넘깁니다. - 검증할 때
algorithms·issuer·audience를 고정합니다. 토큰 헤더의alg는 공격자가 쓰는 값이고, 같은 키로 서명된 다른 용도의 토큰은aud로만 가려낼 수 있습니다. 실제로aud검사를 빼면 다른 서비스용 토큰이 통과하는 것을 확인했습니다. JwtAuthGuard는 모든 실패를 401ACCESS_TOKEN_INVALID하나로 답하고, 통과한 사용자를@CurrentUser()로 컨트롤러에 넘깁니다.UsersModule과AuthModule이 서로를 import 하는 순환을 피하려고 토큰 코드를TokenModule로 분리했습니다.- 로그인에는 IP 당 1분 5회의 시도 제한을 걸고, 제한 응답도
{ statusCode, code, message }모양으로 맞췄습니다. - 지금의
sid는 임시 값입니다. 액세스 토큰이 만료되면 다시 로그인하는 수밖에 없다는 불편함도 확인했습니다.
다음 장에서는 이 두 문제를 함께 풀 리프레시 토큰을 발급합니다. 로그인할 때 불투명한 난수를 만들어 해시만 DB 에 저장하고, 쿠키 속성을 하나하나 정해 브라우저에 내려보냅니다. 그 행의 family id 가 이 장에서 비워 둔 sid 자리를 채웁니다.
연습문제
1. PasswordService.burn 을 다음처럼 "간단하게" 고쳤습니다. 무엇이 문제인가요?
async burn(password: string): Promise<false> {
await argon2.hash(password, OPTIONS);
return false;
}
해답
두 가지가 다릅니다. 첫째, 진짜 사용자 쪽은 verify 를 하고 이쪽은 hash 를 합니다. argon2 에서 두 연산의 비용은 비슷하지만, hash 는 무작위 솔트를 만들고 결과 문자열을 조립하는 일이 더 있어 완전히 같지는 않습니다. 시간을 맞추려면 같은 연산을 하는 편이 확실합니다.
둘째, 이 방식은 요청마다 사용자가 보낸 비밀번호로 해시를 하나씩 새로 만듭니다. 원래 코드는 더미 해시를 한 번만 만들고 그 뒤로는 verify 만 합니다. 해싱 비용 자체는 비슷하더라도, 없는 이메일 요청이 몰릴 때 서버가 하는 일의 종류가 진짜 사용자 요청과 달라집니다. "없는 사용자일 때도 같은 일을 한다" 는 원칙에 가장 가깝게 맞추는 것이 원래 코드의 의도입니다.
2. 5.9 절의 tamper 토큰은 aud·iss·expired 토큰과 달리 비밀 키 없이 만들었습니다. 이 토큰이 막히는 데는 algorithms·issuer·audience 옵션 중 무엇이 필요한가요?
해답
셋 다 필요 없습니다. tamper 는 페이로드를 바꾼 뒤 원래 서명을 그대로 붙인 토큰입니다. 서명은 헤더와 페이로드를 합친 문자열에 대해 계산된 것이므로, 페이로드가 바뀌면 서버가 다시 계산한 서명과 맞지 않습니다. secret 만 있으면 막힙니다.
이 문제의 요점은 옵션마다 막는 공격이 다르다는 것입니다. 서명 검사는 "키를 모르는 사람의 변조" 를, algorithms 는 "검증 방법 자체를 바꾸려는 시도" 를, issuer·audience 는 "키는 맞지만 다른 곳·다른 용도로 발급된 토큰" 을 막습니다. 하나가 다른 것을 대신하지 않습니다.
3. 계정이 삭제된 사용자의 액세스 토큰으로 /users/me 를 부르면 무슨 일이 일어나나요? 그 사용자가 같은 토큰으로 이 책의 다른 보호된 API(예: 7장의 세션 목록)를 부르면 어떨까요?
해답
/users/me 는 가드를 통과한 뒤 DB 에서 사용자를 조회하고, 없으면 401 ACCESS_TOKEN_INVALID 를 답합니다. 프론트엔드는 이것을 토큰 만료와 똑같이 처리해 로그인 화면으로 갑니다.
하지만 가드 자체는 DB 를 보지 않으므로, 사용자를 조회하지 않는 API 라면 토큰이 만료될 때까지(최대 10분) 계속 통과합니다. 이것이 JWT 의 본질적인 성질입니다. 이 책에서는 users 행이 지워지면 refresh_tokens 행도 ON DELETE CASCADE 로 함께 지워지므로, 해당 사용자에 대한 세션 조회 결과는 비게 됩니다. 계정 정지처럼 즉시 끊어야 하는 요구가 있다면 가드에서 DB 를 조회하는 방법이 있지만, 요청마다 쿼리가 하나 늘어나는 비용을 치러야 합니다.
4. 로그인 시도 제한을 IP 가 아니라 이메일 기준으로 세면 어떤 장단점이 있을까요?
해답
장점은 여러 IP 에서 한 계정을 노리는 공격을 막을 수 있다는 점입니다. IP 를 바꿔 가며 같은 계정에 비밀번호를 대입해도 이메일 기준 카운터는 계속 올라갑니다.
단점은 두 가지입니다. 첫째, 공격자가 남의 계정을 잠글 수 있습니다. 피해자의 이메일로 일부러 틀린 로그인을 5번 보내면 피해자는 1분 동안 로그인하지 못합니다. 이것을 계속 반복하면 서비스 거부가 됩니다. 둘째, 크리덴셜 스터핑처럼 계정마다 한두 번씩만 시도하는 공격은 이메일 기준으로도 걸리지 않습니다. 그래서 실무에서는 IP 기준과 계정 기준을 함께 쓰고, 계정 기준은 잠금 대신 추가 확인(CAPTCHA 등)으로 대응하는 경우가 많습니다.
5. 이 장의 login 은 sid 에 randomUUID() 를 넣습니다. 이 상태에서 같은 사용자가 로그인을 두 번 하면 두 토큰의 sid 는 다릅니다. 6장 이후 sid 가 리프레시 토큰의 family id 가 되면, 한 번의 로그인에서 나온 액세스 토큰들의 sid 는 어떻게 될까요? 그것이 왜 유용한가요?
해답
한 번의 로그인이 family 하나를 열고, 그 뒤 리프레시로 새 액세스 토큰을 몇 번 받든 sid 는 모두 같은 family id 입니다. 다른 기기나 다른 브라우저에서 로그인하면 다른 family 가 열리므로 sid 도 다릅니다.
이 성질 덕분에 서버는 액세스 토큰만 보고 "이 요청이 어느 로그인(세션)에서 왔는가" 를 알 수 있습니다. 7장의 세션 목록에서 현재 요청을 보낸 세션에 current: true 를 표시하는 것이 바로 이 비교입니다. 특정 세션만 끊거나, 로그에서 한 세션의 활동을 묶어 보는 데도 씁니다.