
내 사이트가 피싱 사이트의 제물이 되었다 (보안 헤더 완벽 가이드)
어느 날 내 서비스가 낯선 도메인의 iframe 안에서 돌고 있는 걸 발견했습니다. Clickjacking 공격을 막기 위해 HSTS, X-Frame-Options, CSP, Permissions-Policy 등 필수 보안 헤더 6가지를 상세히 설명하고, Nginx와 Next.js에 적용하는 방법을 공유합니다.

어느 날 내 서비스가 낯선 도메인의 iframe 안에서 돌고 있는 걸 발견했습니다. Clickjacking 공격을 막기 위해 HSTS, X-Frame-Options, CSP, Permissions-Policy 등 필수 보안 헤더 6가지를 상세히 설명하고, Nginx와 Next.js에 적용하는 방법을 공유합니다.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

로그를 분석하다가 이상한 Referrer(유입 경로)를 발견했습니다.
fake-event-winner.com이라는 곳에서 트래픽이 쏟아지고 있었습니다.
접속해보니, 제 웹사이트가 그대로 떠 있었습니다.
알고 보니 제 사이트 위에 투명한 버튼을 씌워서, 사용자가 "이벤트 응모" 버튼을 누르면 실제로는 제 사이트의 "송금" 버튼이 눌리게 만드는 클릭재킹(Clickjacking) 공격이었습니다.
소스코드를 보니 <iframe> 태그 하나로 제 사이트를 가두고 있었습니다.
"아니, 남의 사이트를 이렇게 허락도 없이 가져다 쓸 수 있다고?"
네, 가능합니다. 여러분이 서버에서 "보안 헤더(Security Headers)"를 설정하지 않았다면요.
최신 브라우저(Chrome, Safari 등)는 강력한 보안 기능을 내장하고 있습니다. 하지만 이 기능들은 기본적으로 꺼져 있습니다. (하위 호환성 때문이죠.) 서버가 응답 헤더(Response Header)를 통해 "이 기능 켜!"라고 명령해야만 작동합니다.
필수 보안 헤더 6가지를 소개합니다.
사용자가 주소창에 http://naver.com이라고 쳐도, 브라우저가 강제로 https://로 바꿔서 접속하게 합니다.
중간자 공격(MITM)을 원천 차단합니다.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
가장 중요합니다. 다른 사이트가 내 페이지를 <iframe> 안에 넣는 것을 막습니다.
X-Frame-Options: DENY
# 또는
X-Frame-Options: SAMEORIGIN
일명 "MIME Sniffing 차단" 헤더입니다.
해커가 .jpg 파일 확장자를 가진 악성 스크립트 파일을 업로드하고, 브라우저가 이걸 스크립트로 실행하게 만드는 공격을 막습니다.
X-Content-Type-Options: nosniff
"내가 CSS라고 줬으면 CSS로만 읽어. 멋대로 추측해서 실행하지 마."
사용자가 링크를 타고 다른 사이트로 이동할 때, "이전 사이트 주소"를 얼마나 알려줄지 결정합니다. 개인정보 보호에 중요합니다.
Referrer-Policy: strict-origin-when-cross-origin
브라우저의 기능(Feature)을 제한합니다. 내 웹사이트는 지도 앱이 아닌데 위치 정보(Geolocation)나 마이크를 켤 이유가 없죠? 미리 차단해두면 해킹당해도 안전합니다.
Permissions-Policy: geolocation=(), microphone=()
가장 강력하지만 설정하기 까다롭습니다. XSS를 막기 위해 로드할 수 있는 스크립트, 이미지, 스타일의 출처를 화이트리스트로 관리합니다. (자세한 건 XSS 편 참고)
웹 서버 앞단에서 설정하는 게 가장 깔끔합니다.
# nginx.conf
server {
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin";
}
Helmet 라이브러리를 쓰면 한 줄로 끝납니다.
const helmet = require('helmet');
app.use(helmet());
// 끝. 주요 헤더 15개가 자동 적용됨.
next.config.js에서 설정합니다.
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
// ...
],
},
];
},
};
설정을 마쳤다면 securityheaders.com 에 접속해서 여러분의 사이트 주소를 입력해보세요.
대부분의 기본 설정 웹사이트는 D 또는 F가 나옵니다. 해커들에게 "어서 오세요"라고 대문을 열어둔 셈입니다.
보안 헤더 설정은 복잡한 코딩이 필요 없습니다. 설정 파일 몇 줄만 고치면 됩니다. 하지만 그 효과는 엄청납니다.
이 모든 게 헤더 몇 줄로 해결됩니다. 지금 당장 여러분의 사이트를 검사해보세요. "보이지 않는 방패"가 튼튼한지 확인하세요.
보안을 위해 추가해야 할 헤더도 있지만, 삭제해야 할 헤더도 있습니다.
바로 X-Powered-By와 Server 헤더입니다.
X-Powered-By: ExpressServer: nginx/1.18.0이 헤더들은 해커에게 "나 Express 쓰고, Nginx 1.18 버전 써요"라고 광고하는 꼴입니다. 해커는 해당 버전의 취약점(CVE)을 검색해서 공격합니다. 그러니 반드시 숨기세요.
app.disable('x-powered-by'); // Express
server_tokens off; // Nginx
정보를 숨기는 것(Security through obscurity)이 완벽한 보안은 아니지만, 굳이 해커에게 지도를 쥐여줄 필요는 없습니다.
While analyzing logs, I noticed a strange Referrer URL.
Traffic was pouring in from fake-event-winner.com.
I visited the URL and saw my website exactly as it is.
It was a Clickjacking attack. They overlaid a transparent button on top of my site using CSS. When users clicked "Claim Prize" on their site, they were actually clicking "Transfer Money" on my site loaded inside an invisible iframe.
Looking at the source code, a single <iframe> tag had trapped my site.
"Wait, anyone can just embed my site like this without permission?"
Yes. Unless you configure "Security Headers" on your server.
Modern browsers (Chrome, Safari, etc.) have powerful built-in security features. But these features are off by default to maintain backward compatibility. The server must send specific Response Headers to say "Turn this ON!"
Here are the 6 Essential Security Headers you must know.
Even if a user types http://, the browser forces the connection to https://.
It prevents Man-in-the-Middle (MITM) attacks (e.g., stripping SSL at a public Wi-Fi).
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Most important. It stops other sites from embedding your page in <iframe>, <frame>, or <object>.
X-Frame-Options: DENY
# or
X-Frame-Options: SAMEORIGIN
Stops MIME Sniffing.
Prevents attacks where a hacker uploads a malicious file disguised as an image (.jpg), and the browser tries to "guess" and execute it as a script.
X-Content-Type-Options: nosniff
"If I said it's an image, treat it as an image. Don't try to run it."
Controls how much information is passed in the Referer header when a user clicks a link to leave your site.
Referrer-Policy: strict-origin-when-cross-origin
Prevents leaking private URLs (like /reset-password?token=...) to third-party analytics.
Limits browser features. If your blog doesn't need the Camera, Microphone, or Geolocation, block them. Even if you get XSS'd, the hacker can't spy on the user.
Permissions-Policy: geolocation=(), microphone=()
The most powerful header. It defines a whitelist of trusted sources for scripts, styles, and images. It creates a sandbox that prevents XSS. (See the XSS article for details).
Configuring at the Web Server level is the most robust way.
# nginx.conf inside server block
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin";
Use the Helmet middleware. It's the industry standard.
const helmet = require('helmet');
app.use(helmet());
// Done. It automatically sets 15+ security headers.
Configure in next.config.js.
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
// ...
],
},
];
},
};
After configuring, go to securityheaders.com and scan your URL.
Most default deployments score a D or F. They are practically inviting hackers in.
Security headers are not rocket science. They are just a few lines of configuration. But their impact is massive.
All of this for free. Check your site today. Ensure your "Invisible Shield" is active.
Security headers are evolving. Here are two emerging ones:
We briefly mentioned this, but it's powerful. You can control:
accelerometercamerageolocationgyroscopemagnetometermicrophonepaymentusbBy explicitly disabling these (=()), you reduce the attack surface. Even if a hacker injects malicious JS, they cannot activate the webcam.
Prevents the use of misissued SSL certificates. It tells the browser to check if the certificate appears in public CT logs.
Expect-CT: max-age=86400, enforce, report-uri="https://..."
If a rogue CA issues a fake certificate for your domain, this header ensures browsers reject it.
Setting Strict-Transport-Security header helps, but the very first connection might still be HTTP (and vulnerable).
To fix this, you can submit your domain to the Chrome HSTS Preload List (hstspreload.org).
Once accepted, your domain is hardcoded into Chrome/Firefox/Safari as "HTTPS Only".
Even the first connection will be secure. Warning: Undoing this is very difficult and takes months to propagate.
We usually think of Cache-Control for performance, but it's vital for security too.
If your app displays sensitive data (e.g., Bank Balance, Medical Record), you MUST prevent shared computers (Internet Cafe) or proxies from caching it.
Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate
Pragma: no-cache
Expires: 0
This ensures that when the user logs out, going "Back" in the browser doesn't show the sensitive page again.
Why did I recommend strict-origin-when-cross-origin?
https://site.com), stripping the path.