OAuth를 구현하던 중 Callback API 엔드포인트의 쿼리에 state를 추가했는데 이게 CSRF 공격을 방어하기 위한 것임을 알게 됐다. CSRF에 대해 이해하게 된 과정과 이해한 내용을 정리해보려 한다.
먼저 내가 OAuth에서 CSRF 공격이 어떻게 일어나는지 이해한 과정을 정리해보려 한다. 일단 클로드에게 CSRF에 대해 물어봤다.

사실 처음엔 이 과정이 이해가 되지 않았다. 사용자가 공격자의 OAuth 계정으로 인증하게 되면 그냥 공격자의 계정으로 로그인하게 되는 건데 어떤 문제가 있는 걸까? 라고 생각했다.
알고보니 기존에 사용자가 이미 해당 서비스에 로그인이 돼있는 상태로 OAuth로그인을 하면 해당 계정에 OAuth 로그인 수단이 연결되는 것이었다. 그러므로 사용자의 계정에 공격자가 OAuth를 통해 로그인할 수 있게 되는 것이다.

나는 OAuth 인증을 할 때 이메일을 기준으로 다음과 같이 처리했다.
이렇게 했기 때문에 공격을 받아도 이메일이 일치하지 않아서 공격자의 계정이 새로 추가될 뿐이었다. 의도하지 않았지만 CSRF 공격을 막도록 구현한 것이다. 이러한 부분 때문에 이 과정을 이해하는데 조금 오래 걸렸던 것 같다.
공격이 어떤식으로 이루어지는지 이해하는데 도움이 된 것은 state였다.
state는 사용자가 OAuth 인증을 완료한 후 리다이렉트 시키는 Callback API에 쿼리 파라미터로 포함된다. 이를 통해 CSRF 공격은 이 Callback API에서 이루어지며 state 값을 통해 검증하면 CSRF 공격을 막을 수 있다는 것을 알 수 있었다. State에 대해서는 밑에서 설명할 예정이다.
그러면 CSRF 공격이 구체적으로 어떻게 이루어지는지를 알아보자.
CSRF 공격이 일어나는 과정은 다음과 같다.
예를 들어 만약 사용자가 특정 은행 앱에 로그인 된 상태에서 악성 사이트에 접속하면 송금하는 API가 호출되고 송금이 이루어지는 것이다.
이러한 공격이 가능한 이유는 브라우저가 API를 호출할 때 쿠키를 자동으로 전달하기 때문이다. 그렇기 때문에 만약 권한 인증 수단을 쿠키를 통해 전달하는 경우 CSRF 공격에 노출될 수 있다. 이를 막기 위한 방법이 몇가지 있다.
서버에서 권한 인증 수단을 쿠키로 응답할 때 sameSite 옵션을 lax로 설정하는 것이다.
이렇게 되면 HTTP 메서드 중 GET을 제외한 모든 메서드에 대해 다른 사이트에서 쿠키를 전달하지 못하게 된다.
2020년 2월 이후로 크롬은 SameSite 속성이 없는 쿠키에 대해 SameSite=Lax로 처리하도록 바뀌었다. 많은 브라우저들이 이러한 기능을 지원하지만 지원하지 않는 브라우저도 있기 때문에 SameSite를 lax로 설정하는 것이 중요하다.
옵션 중에 Strict도 있지만 설정할 경우 Get 요청을 포함한 모든 요청에 대해 다른 사이트에서 쿠키를 전달하는게 불가능해지기 때문에 제한사항이 많이 생긴다.
예를 들어 A라는 서비스가 페이지를 이동할 때마다 쿠키를 통해 사용자의 로그인 상태를 확인하도록 설정했을 경우 다른 사이트에서 링크를 통해 A로 이동하게 되면 로그인이 된 상태더라도 쿠키를 전달하지 못해서 로그인 여부 검증에 실패하는 것이다.
그렇기 때문에 SameSite는 lax로 설정하는 것이 좋다.
GET 메서드를 사용하는 API가 리소스의 상태를 변경하지 않는다는 가정 하에선 GET 메서드는 CSRF 공격에 안전하다고 볼 수 있다. 다만 GET 요청으로 민감한 정보를 불러오는 API가 있다면 별도의 처리가 필요하다.
권한 인증 수단으로 JWT를 사용하고 있다면 전달할 때 Authorization header를 사용하면 CSRF공격을 방어할 수 있다.
API를 호출할 때 자동으로 전달되는 쿠키와 다르게 Authorization header는 보통 localstorage에 토큰을 저장했다가 API를 호출할 때 마다 Header에 포함시키기 때문이다.
이렇게 되면 악성 사이트에서는 토큰을 헤더에 포함시킬 수 없기 때문에 CSRF 공격을 막을 수 있다.
다만 Next.js의 미들웨어나 서버 사이드 렌더링 시에는 localstorage를 사용할 수 없기 때문에 header에 토큰을 포함시킬 수 없다는 점이나 Session을 사용한다면 쿠키 방식을 사용하기 때문에 문제점이 있다.
그 다음은 CSRF 토큰을 활용하는 방법이다. CSRF 공격의 핵심은 쿠키 자동 전달이기 때문에
요청을 보낼 때 header에 CSRF 토큰을 포함시키고 서버에서 이 토큰을 검증하도록 설정해서 CSRF 공격을 방어하는 것이다.
이렇게 하면 공격자의 사이트에서 API를 호출하더라도 CSRF 토큰이 누락된 상태이기 때문에 API 호출에 실패하게 된다.
처음 로그인을 하면 CSRF 토큰을 응답하고 프론트엔드에서 요청을 보낼 때마다 이 토큰을 x-csrf-token 헤더에 포함시켜서 보내도록 설정하면 된다. 이때 token을 쿠키로 응답할 수도 있고 body나 header에 포함시켜서 응답하고 로컬스토리지에 저장하는 방식이 있다.
쿠키로 응답할 땐 httpOnly를 false로 처리해야 요청을 보낼 때 CSRF 토큰에 접근해서 보낼 수 있다. CSRF 토큰의 경우 외부에서 사용을 금해야하기 때문에 SameSite를 Strict로 설정한다.
그리고 CSRF토큰을 저장하는 쿠키의 생명주기는 로그인할 때 발급하는 refreshToken과 동일하게 설정하고 로그아웃 할 땐 쿠키를 삭제해줘야 한다.
CSRF 토큰을 구현하던 중 Double Submit Cookie 방식에 대해 알게 됐다. 이 방식은 길이가 긴 랜덤 값을 생성해서 쿠키에 담고 x-CSRF-Token 헤더에도 포함시키는 것이다.
공격자는 헤더를 위조할 순 있지만 브라우저에서 보내는 쿠키는 조작할 수 없기 때문에 헤더의 값과 쿠키의 값을 비교해서 일치 여부를 확인해서 CSRF 공격을 막을 수 있는 것이다.
나는 로그인을 하지 않아도 이 CSRF 토큰을 포함시키고 싶었다. 하지만 기존에 백엔드에서 로그인할 때 토큰을 발급하는 방식으론 로그인을 해야만 토큰을 발급받을 수 있다.
그래서 프론트엔드에서 Next API Route를 통해 토큰을 생성해 쿠키에 담고 Axios의 request interceptor에서 Header에 포함시키도록 구현했다.
(처음엔 JWT Secret을 사용하지 않기 때문에 굳이 API Route로 할 필요가 있을까 생각했지만 쿠키에 토큰을 설정하려면 서버 사이드에서 실행해야하기 때문에 API Route를 쓰는 것이 적절하다.)
그리고 백엔드에서 쿠키에 담긴 CSRF 토큰과 x-CSRF-Token 헤더에 담긴 토큰을 비교하는 CSRF 미들웨어를 구현했다.
// @/app/api/csrf-token/route.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
export async function GET() {
const token = crypto.randomBytes(64).toString('hex');
const response = NextResponse.json({ message: 'CSRF Token 생성 완료' });
response.cookies.set('csrfToken', token, {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 1000 * 60 * 5,
path: '/',
});
return response;
}
// axiosInstance.ts
const getCSRFTokenFromCookie = () => {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(/csrfToken=([^;]+)/);
return match ? match[1] : null;
};
axiosInstance.interceptors.request.use(
async (config) => {
let csrfToken = getCSRFTokenFromCookie();
if (!csrfToken) {
await fetch('/api/csrf-token', {
method: 'GET',
credentials: 'include',
});
csrfToken = getCSRFTokenFromCookie();
}
if (csrfToken) config.headers['X-CSRF-Token'] = csrfToken;
return config;
},
(error) => {
return Promise.reject(error);
},
);
// @/middleware/csrf.ts
import { NextFunction, Request, Response } from 'express';
export const csrfMiddleware = (req: Request, res: Response, next: NextFunction) => {
const csrfTokenFromCookie = req.cookies.csrfToken;
const csrfTokenFromHeader = req.headers['x-csrf-token'];
const csrfTokenMatch = csrfTokenFromCookie === csrfTokenFromHeader;
if (!csrfTokenMatch || !csrfTokenFromCookie || !csrfTokenFromHeader) {
res.status(403).json({ message: '외부에서 조회할 수 없는 API입니다.' });
return;
}
return next();
};
OAuth와 같이 Callback API를 제공하는 경우 CSRF를 막을 때 state를 사용할 수 있다.
먼저 OAuth의 절차를 간단히 설명하고 State로 CSRF 공격을 막는 방법을 설명하려 한다.
OAuth 로그인을 시도하면 먼저 해당 OAuth 프로바이더의 로그인 페이지로 이동한다. 이때 로그인 페이지 URL에는 사용자가 로그인 후에 리다이렉트(호출)할 Callback API의 URI가 포함되어 있다.
그리고 사용자가 로그인을 하면 Callback API의 URI로 리다이렉트 되는데 이때 쿼리 파라미터로 code라는 값이 추가된다. 이 code는 사용자의 정보를 요청할 때 쓰는 accessToken을 발급받기 위한 코드이다. 이 액세스 토큰을 사용해 요청한 정보를 서버에 보내서 새로 계정을 추가하거나 기존 계정에 연결하게 되는 것이다.
Callback API의 URI와 자신의 code는 노출되어 있기 때문에 공격자가 이를 수집해서 악성 사이트에 이 API를 호출하도록 설정해놓는다.
이때 만약 사용자가 해당 서비스에 로그인 된 상태에서 API를 호출하면 사용자의 계정과 공격자의 OAuth가 연결되는 것이다.
이러한 공격을 막기 위해 URI에 포함된 state를 통해 해당 Callback API URI가 서버에서 제공한 것인지 검증하는 방법을 쓴다.
State는 서버에서 발급한 값이 맞는지 검증할 수 있어야 한다. 그래서 나는 JWT를 활용했고 만료 시간을 5분으로 짧게 잡아서 노출되더라도 재사용할 수 없도록 했다.
oAuthCallback = async (req: Request, res: Response, next?: NextFunction) => {
const { provider } = req.params;
const { state } = req.query;
let userType: LowercaseUserType = 'customer';
if (!state) {
return this.redirectToError(userType, this.FAIL_QUERY.invalidRequest, res);
}
if (typeof state === 'string') {
try {
const decodedState = this.authService.decodeState(state);
userType = decodedState.userType as LowercaseUserType;
} catch (error) {
console.error('상태 정보 디코딩 실패:', error);
return this.redirectToError(userType, this.FAIL_QUERY.invalidRequest, res);
}
}
generateState(data: { userType: string }) {
const stateObj = {
userType: data.userType,
};
const token = jwt.sign(stateObj, process.env.OAUTH_STATE_SECRET!, {
expiresIn: '5m',
});
return encodeURIComponent(token);
}
decodeState(state: string) {
const decoded = jwt.verify(
decodeURIComponent(state),
process.env.OAUTH_STATE_SECRET!,
) as JwtPayload;
return {
userType: decoded.userType,
};
}