OAuth2 PKCE란?
PKCE(Proof Key for Code Exchange)는 OAuth2 Authorization Code Flow의 보안 확장입니다. 원래 모바일/SPA 등 client_secret을 안전하게 보관할 수 없는 퍼블릭 클라이언트를 위해 설계되었지만, OAuth 2.1에서는 모든 클라이언트에 PKCE가 필수로 지정되었습니다. NestJS 백엔드에서도 BFF(Backend for Frontend) 패턴으로 PKCE를 직접 처리하는 것이 현대적 인증 아키텍처의 표준입니다.
1. PKCE 흐름 이해
기존 Authorization Code Flow에 code_verifier/code_challenge 쌍이 추가됩니다.
| 단계 | 동작 | PKCE 추가 요소 |
|---|---|---|
| 1. 인증 요청 | 클라이언트 → IdP 리다이렉트 | code_challenge + code_challenge_method=S256 |
| 2. 사용자 인증 | IdP에서 로그인/동의 | 변경 없음 |
| 3. 콜백 수신 | IdP → 클라이언트 (authorization code) | 변경 없음 |
| 4. 토큰 교환 | 클라이언트 → IdP (code → token) | code_verifier 전송 (IdP가 검증) |
// PKCE 핵심: code_verifier → SHA256 → Base64URL = code_challenge
import { randomBytes, createHash } from 'crypto';
function generatePKCE() {
// 1. 랜덤 code_verifier 생성 (43~128자)
const verifier = randomBytes(32)
.toString('base64url');
// 2. SHA256 해시 → Base64URL = code_challenge
const challenge = createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// verifier는 세션에 저장, challenge만 IdP에 전송
// 토큰 교환 시 verifier를 보내면 IdP가 다시 해시하여 검증
2. NestJS 프로젝트 설정
npm install @nestjs/passport passport passport-oauth2
npm install express-session @nestjs/config
npm install -D @types/passport-oauth2 @types/express-session
// app.module.ts
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
AuthModule,
],
})
export class AppModule {}
// main.ts — 세션 설정 (code_verifier 저장용)
import * as session from 'express-session';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 10 * 60 * 1000, // 10분 (인증 흐름 완료용)
},
}));
await app.listen(3000);
}
3. PKCE Strategy 구현
Passport의 passport-oauth2를 확장하여 PKCE 파라미터를 주입합니다.
// auth/strategies/oauth2-pkce.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-oauth2';
import { ConfigService } from '@nestjs/config';
import { randomBytes, createHash } from 'crypto';
@Injectable()
export class OAuth2PkceStrategy extends PassportStrategy(Strategy, 'oauth2-pkce') {
constructor(private config: ConfigService) {
super({
authorizationURL: config.get('OAUTH_AUTH_URL'),
tokenURL: config.get('OAUTH_TOKEN_URL'),
clientID: config.get('OAUTH_CLIENT_ID'),
clientSecret: config.get('OAUTH_CLIENT_SECRET'), // confidential client
callbackURL: config.get('OAUTH_CALLBACK_URL'),
scope: ['openid', 'profile', 'email'],
// PKCE 활성화
pkce: true,
state: true,
passReqToCallback: true,
});
}
// 토큰 교환 성공 후 호출
async validate(
req: any,
accessToken: string,
refreshToken: string,
profile: any,
) {
// IdP의 userinfo 엔드포인트에서 프로필 조회
const userInfo = await this.fetchUserInfo(accessToken);
return {
id: userInfo.sub,
email: userInfo.email,
name: userInfo.name,
accessToken,
refreshToken,
};
}
private async fetchUserInfo(accessToken: string) {
const res = await fetch(this.config.get('OAUTH_USERINFO_URL'), {
headers: { Authorization: `Bearer ${accessToken}` },
});
return res.json();
}
}
4. Auth Controller: 로그인·콜백·로그아웃
// auth/auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
// 1단계: IdP로 리다이렉트 (code_challenge 자동 포함)
@Get('login')
@UseGuards(AuthGuard('oauth2-pkce'))
login() {
// Passport가 자동으로 IdP authorization URL로 리다이렉트
}
// 3단계: 콜백 수신 → 토큰 교환 (code_verifier 자동 전송)
@Get('callback')
@UseGuards(AuthGuard('oauth2-pkce'))
async callback(@Req() req: Request, @Res() res: Response) {
const user = req.user;
// 자체 JWT 발급 (BFF 패턴)
const { accessToken, refreshToken } =
await this.authService.issueTokens(user);
// HttpOnly 쿠키로 전달
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15분
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
});
res.redirect(process.env.FRONTEND_URL);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Req() req: Request, @Res() res: Response) {
await this.authService.revokeRefreshToken(req.user.id);
res.clearCookie('access_token');
res.clearCookie('refresh_token', { path: '/auth/refresh' });
// IdP 로그아웃 (선택)
const logoutUrl = `${process.env.OAUTH_LOGOUT_URL}?post_logout_redirect_uri=${process.env.FRONTEND_URL}`;
res.json({ logoutUrl });
}
@Post('refresh')
async refresh(@Req() req: Request, @Res() res: Response) {
const oldToken = req.cookies['refresh_token'];
if (!oldToken) throw new UnauthorizedException();
const { accessToken, refreshToken } =
await this.authService.rotateTokens(oldToken);
res.cookie('access_token', accessToken, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 15 * 60 * 1000,
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ success: true });
}
}
5. 수동 PKCE 구현 (passport-oauth2 미사용)
Passport의 pkce: true 옵션이 없는 커스텀 IdP나 세밀한 제어가 필요한 경우, PKCE를 직접 구현합니다.
// auth/pkce.service.ts
@Injectable()
export class PkceService {
constructor(
private config: ConfigService,
private httpService: HttpService,
) {}
// 인증 URL 생성 + verifier를 세션에 저장
generateAuthUrl(session: Record<string, any>): string {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256')
.update(verifier)
.digest('base64url');
const state = randomBytes(16).toString('hex');
// 세션에 저장 (콜백에서 사용)
session.pkceVerifier = verifier;
session.oauthState = state;
const params = new URLSearchParams({
response_type: 'code',
client_id: this.config.get('OAUTH_CLIENT_ID'),
redirect_uri: this.config.get('OAUTH_CALLBACK_URL'),
scope: 'openid profile email',
code_challenge: challenge,
code_challenge_method: 'S256',
state,
});
return `${this.config.get('OAUTH_AUTH_URL')}?${params}`;
}
// 콜백에서 토큰 교환
async exchangeCode(
code: string,
session: Record<string, any>,
receivedState: string,
) {
// CSRF 검증
if (receivedState !== session.oauthState) {
throw new UnauthorizedException('State mismatch');
}
const verifier = session.pkceVerifier;
if (!verifier) {
throw new UnauthorizedException('Missing PKCE verifier');
}
// 세션에서 즉시 제거 (재사용 방지)
delete session.pkceVerifier;
delete session.oauthState;
const { data } = await firstValueFrom(
this.httpService.post(this.config.get('OAUTH_TOKEN_URL'),
new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.config.get('OAUTH_CALLBACK_URL'),
client_id: this.config.get('OAUTH_CLIENT_ID'),
client_secret: this.config.get('OAUTH_CLIENT_SECRET'),
code_verifier: verifier, // PKCE 핵심!
}).toString(),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
),
);
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
idToken: data.id_token,
expiresIn: data.expires_in,
};
}
}
6. BFF 패턴: 토큰 관리 아키텍처
PKCE와 함께 사용하는 BFF(Backend for Frontend) 패턴에서 토큰 관리 흐름입니다.
| 토큰 | 저장 위치 | 전달 방식 | TTL |
|---|---|---|---|
| IdP Access Token | NestJS 서버 (Redis) | 프론트에 노출 안 함 | IdP 설정 |
| 자체 Access Token | HttpOnly Cookie | 자동 전송 (SameSite) | 15분 |
| Refresh Token | HttpOnly Cookie (path 제한) | /auth/refresh만 | 7일 |
| code_verifier | 서버 세션 (임시) | 인증 흐름 중에만 | ~10분 |
// auth/auth.service.ts — 토큰 관리
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
@InjectRedis() private redis: Redis,
private userService: UserService,
) {}
async issueTokens(oauthUser: OAuthUser) {
// 사용자 upsert
const user = await this.userService.upsertFromOAuth(oauthUser);
// IdP 토큰을 서버에 보관 (API 호출용)
await this.redis.set(
`idp_token:${user.id}`,
JSON.stringify({
accessToken: oauthUser.accessToken,
refreshToken: oauthUser.refreshToken,
}),
'EX', 3600,
);
// 자체 JWT 발급
const accessToken = this.jwtService.sign(
{ sub: user.id, email: user.email },
{ expiresIn: '15m' },
);
const refreshToken = randomBytes(32).toString('hex');
await this.redis.set(
`refresh:${refreshToken}`,
user.id,
'EX', 7 * 24 * 3600,
);
return { accessToken, refreshToken };
}
async rotateTokens(oldRefreshToken: string) {
const userId = await this.redis.get(`refresh:${oldRefreshToken}`);
if (!userId) throw new UnauthorizedException('Invalid refresh token');
// 기존 토큰 즉시 무효화 (Rotation)
await this.redis.del(`refresh:${oldRefreshToken}`);
const user = await this.userService.findById(userId);
const accessToken = this.jwtService.sign(
{ sub: user.id, email: user.email },
{ expiresIn: '15m' },
);
const refreshToken = randomBytes(32).toString('hex');
await this.redis.set(`refresh:${refreshToken}`, user.id, 'EX', 7 * 24 * 3600);
return { accessToken, refreshToken };
}
}
7. 보안 체크리스트
PKCE 구현 시 반드시 확인해야 할 보안 항목들입니다.
| 항목 | 필수 여부 | 설명 |
|---|---|---|
| S256 메서드 사용 | 필수 | plain 방식은 보안상 의미 없음 |
| state 파라미터 검증 | 필수 | CSRF 공격 방지 |
| code_verifier 일회성 | 필수 | 사용 후 세션에서 즉시 삭제 |
| HTTPS 강제 | 필수 | 코드/토큰 탈취 방지 |
| Refresh Token Rotation | 권장 | 토큰 탈취 시 피해 최소화 |
| redirect_uri 완전 일치 | 필수 | 와일드카드/패턴 매칭 금지 |
8. E2E 테스트
// test/auth-pkce.e2e-spec.ts
describe('OAuth2 PKCE Flow', () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(session({ secret: 'test', resave: false, saveUninitialized: false }));
await app.init();
});
it('/auth/login → IdP로 리다이렉트 + code_challenge 포함', async () => {
const res = await request(app.getHttpServer())
.get('/auth/login')
.expect(302);
const location = new URL(res.headers.location);
expect(location.searchParams.get('code_challenge')).toBeDefined();
expect(location.searchParams.get('code_challenge_method')).toBe('S256');
expect(location.searchParams.get('state')).toBeDefined();
expect(location.searchParams.get('response_type')).toBe('code');
});
it('state 불일치 시 401 반환', async () => {
await request(app.getHttpServer())
.get('/auth/callback?code=fake&state=wrong')
.expect(401);
});
});
마무리
OAuth2 PKCE는 OAuth 2.1 필수 스펙으로, NestJS에서 Passport의 pkce: true 옵션 또는 수동 구현으로 적용할 수 있습니다. BFF 패턴과 결합하면 프론트엔드에 토큰을 노출하지 않는 안전한 인증 아키텍처가 완성됩니다. NestJS Passport 인증 전략으로 기본 JWT/Local 인증을 이해한 뒤, NestJS Guard 인가 설계와 함께 PKCE 기반 인증을 적용하는 것을 권장합니다.