들어가며
사내 포털을 마이크로프론트엔드 (MFE) 구조로 운영 중이다. shell 앱이 여러 sub 앱을 iframe 으로 띄워서 보여주는 구조인데, 그 안에서 Keycloak 으로 보호되는 다른 앱을 iframe 으로 임베드하려고 했다. 결과는 브라우저 콘솔에 정겨운 한 줄.
Refused to display 'https://app.example.com/' in a frame because
an ancestor violates the following Content Security Policy directive:
"frame-ancestors 'self'".
원인은 한 번에 보이는데 해결까지 30분 이상 잡아먹었다. Keycloak Admin UI 의 silent save fail 함정 때문이었다. 검색해서 오신 분들 위해, 함정과 해결을 모두 정리한다.
핵심 키워드: Keycloak, Content-Security-Policy, frame-ancestors, X-Frame-Options, iframe 임베드, Refused to display in a frame, browserSecurityHeaders.
시스템 컨텍스트
- 부모 페이지: https://portal.example.com/ (마이크로프론트엔드 shell)
- iframe 으로 임베드할 대상: https://app.example.com/ (Keycloak 인증으로 보호)
- Keycloak 버전: 24.x
- 운영: Helm chart 로 배포, Realm 설정은 GitOps 로 관리
브라우저
└─ portal.example.com (shell)
└─
└─ app.example.com (Keycloak 인증)
문제 상황
iframe 이 빈 화면. 콘솔에 다음 메시지.
Refused to display 'https://app.example.com/' in a frame because
an ancestor violates the following Content Security Policy directive:
"frame-ancestors 'self'".
이 메시지의 의미.
- frame-ancestors = "이 페이지를 iframe 으로 띄우는 부모 페이지의 origin 화이트리스트".
- 'self' = "자기 자신과 같은 origin 만 허용".
- 즉 app.example.com 자체에서 자기를 iframe 으로 띄우는 건 OK, 다른 origin (portal.example.com) 은 금지.
Keycloak default 가 frame-ancestors 'self' 라서, 다른 origin 에서 임베드하려면 Realm 의 CSP 헤더와 X-Frame-Options 를 풀어줘야 한다.
frame-ancestors vs X-Frame-Options vs frame-src 정리
헷갈리니까 한 번 정리.
헤더누가 누구를 통제하는가표준 상태
| frame-ancestors (CSP) | 임베드 당하는 쪽 이 "누가 나를 iframe 으로 띄울 수 있는지" 선언 | 신표준 (우선순위 ↑) |
| X-Frame-Options | 동일 목적, but 단순 값 (DENY / SAMEORIGIN / ALLOW-FROM) | 레거시, but 일부 브라우저는 여전히 참고 |
| frame-src (CSP) | iframe 을 띄우는 쪽 이 "어떤 origin 을 iframe 으로 띄울 수 있는지" 선언 | 신표준 |
오늘 다루는 건 첫 두 개. iframe 의 부모(shell) 에서 띄우는 쪽이 아니라 임베드 당하는 Keycloak 보호 페이지의 헤더가 막고 있는 케이스.
시도한 것들
시도 1 — Realm Security Defenses 에서 CSP 직접 수정
Keycloak Admin UI → Realm settings → Security Defenses → Headers 탭. 거기에 Content-Security-Policy 필드가 있다. 기본값:
frame-src 'self'; frame-ancestors 'self'; object-src 'none';
frame-ancestors 'self' 를 허용 origin 으로 바꿨다.
frame-src 'self'; frame-ancestors 'self' https://portal.example.com; object-src 'none';
Save 버튼 클릭. 그런데 변경이 저장되지 않는 현상.
브라우저에서 새로고침하면 default 로 돌아와 있다. 직접 Admin REST API 로 GET 해봐도 그대로.
TOKEN=$(curl -s -X POST \
"https://kc.example.com/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli" -d "username=admin" -d "password=admin" \
-d "grant_type=password" | jq -r .access_token)
curl -s -H "Authorization: Bearer $TOKEN" \
"https://kc.example.com/admin/realms/myrealm" | jq '.browserSecurityHeaders'
응답에 contentSecurityPolicy 가 default 값 그대로. UI 에는 분명 변경값이 보이는데 백엔드에 persist 가 안 됨.
시도 2 — UI 함정: paste 시 change detection 미발동
원인을 찾았다. 붙여넣기로만 값을 수정하면 Keycloak Admin UI 가 form change 를 감지 못 하는 케이스가 있다. Save 버튼이 grayed out (disabled) 인 채로 머무는 게 단서.
- 눈으로 보면 grayed out 인데 클릭은 된다. 시각적 disabled 상태인데 클릭이 막혀 있지 않아서 "눌렀다" 라고 느끼지만 onClick 이 안 도는 상태.
- 콘솔의 Network 탭을 보면 PUT 요청 자체가 나가지 않음. 즉 silent fail.
해결 트릭 (수많은 SPA 폼에서 통한다):
- CSP 필드를 클릭해서 커서를 안에 둠.
- 맨 뒤에 스페이스 한 칸 추가.
- 다시 백스페이스로 그 스페이스를 지움.
- 그 순간 React/Angular 의 change event 가 발동되어 Save 버튼이 활성화 (옅은 색 → 진한 색으로 바뀜).
- Save 클릭.
이 트릭은 paste-only 변경에서 dirty 감지를 놓치는 모든 폼에서 통용된다. Keycloak 외에도 일부 운영 콘솔에서 자주 만난다.
시도 3 — X-Frame-Options 도 같이 풀기
CSP 만 풀어도 일부 브라우저/구성에서는 X-Frame-Options 가 별도로 차단한다. Keycloak 의 X-Frame-Options 기본값은 SAMEORIGIN.
같은 Security Defenses 페이지에서 X-Frame-Options 를 둘 중 하나로:
- 비워서 헤더 자체를 안 보내게 (CSP 의 frame-ancestors 가 충분히 통제).
- ALLOW-FROM https://portal.example.com (레거시 브라우저 호환). 단, 모던 브라우저는 ALLOW-FROM 을 무시하니 신뢰하지 말 것.
가장 안전한 선택: 헤더 비워두고 CSP frame-ancestors 에 의존. 모던 브라우저 표준이고, 우선순위도 CSP 가 위.
시도 4 — 영구화 (Helm / Realm export)
여기까지 와서 Admin UI 변경이 다음 deploy 에서 사라졌다. 우리 환경은 Realm 설정이 GitOps 로 관리되는데, realm.json 의 browserSecurityHeaders 가 default 인 채여서 Helm sync 가 그걸 덮어쓴 것.
해결: realm.json 의 browserSecurityHeaders 를 함께 수정.
{
"realm": "myrealm",
"...": "...",
"browserSecurityHeaders": {
"contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self' https://portal.example.com; object-src 'none';",
"contentSecurityPolicyReportOnly": "",
"xContentTypeOptions": "nosniff",
"xFrameOptions": "",
"strictTransportSecurity": "max-age=31536000; includeSubDomains",
"xRobotsTag": "none",
"referrerPolicy": "no-referrer"
}
}
Helm values 에 이걸 inject 하거나 realm import 시 적용. 이로써 다음 deploy 에서도 살아남는다.
시도 5 — 검증
다시 portal 에서 iframe 으로 app 을 열면 빈 화면 대신 Keycloak 로그인 페이지(또는 이미 로그인된 경우 앱) 가 정상 표시.
# 헤더가 실제로 어떻게 나가는지 확인
curl -sI https://app.example.com/ | grep -iE 'content-security-policy|x-frame-options'
기대 출력:
content-security-policy: frame-src 'self'; frame-ancestors 'self' https://portal.example.com; object-src 'none';
X-Frame-Options 헤더가 없거나 비어 있어야 한다 (있으면 모던 브라우저가 CSP 보다 약한 정책으로 차단 가능성).
추가 함정 — 여러 origin 허용
frame-ancestors 는 공백으로 구분된 source list. 여러 origin 을 허용하려면:
frame-ancestors 'self' https://portal.example.com https://admin.example.com;
wildcard 도 가능하지만 보안상 권장 안 함:
frame-ancestors 'self' https://*.example.com;
production 에서는 정확한 origin 만 화이트리스트.
원인 정리
세 가지가 겹쳤다.
- Keycloak default CSP 가 자기 자신만 iframe 임베드 허용 (frame-ancestors 'self').
- Admin UI 의 paste 변경이 dirty 감지를 놓쳐서 Save 가 silently fail (Save 버튼 grayed out 이 단서).
- 우리 환경의 GitOps 가 realm.json 을 덮어써서, UI 변경이 다음 deploy 에 사라짐.
해결 — 최종 절차
- Realm settings → Security Defenses → Headers 에서 CSP / X-Frame-Options 변경.
- paste 후 스페이스+백스페이스 트릭으로 Save 버튼 강제 활성화.
- Save 후 Network 탭에서 PUT 요청이 실제로 나갔는지 확인.
- realm.json 의 browserSecurityHeaders 도 함께 수정하여 GitOps deploy 에서 살아남게 처리.
- curl -I 로 헤더가 실제로 어떻게 나가는지 검증.
디버깅 체크리스트 (같은 문제 다시 만났을 때)
- 브라우저 콘솔에 Refused to display ... frame-ancestors 가 보이면 100% CSP 이슈. (X-Frame-Options 였다면 메시지 문구가 다르다)
- Admin UI 에서 변경 후 Save 가 안 먹히는 느낌이면 DevTools Network 탭에서 PUT 이 나갔는지 확인. 1초 안에 판단 가능.
- curl -I 로 실제 응답 헤더 확인. 브라우저는 캐시·extension 때문에 noise 가 있다.
- 모던 브라우저는 X-Frame-Options 의 ALLOW-FROM 무시. CSP 의 frame-ancestors 에 의존하자.
- GitOps 환경이면 UI 변경 이전에 source-of-truth 파일부터 수정.
배운 점
- CSP 수정이 안 먹히면 진짜 안 먹힌 건지 Admin UI 가 안 보낸 건지부터 분리하자. DevTools Network 탭이 1초만에 판단해준다.
- paste-only 변경은 SPA 의 change detection 을 놓치는 경우가 많다. Keycloak 뿐 아니라 다른 운영 콘솔에서도 빈번. 스페이스+백스페이스 트릭은 반사적으로 쓸 수 있게 외워두는 게 좋다.
- iframe 임베드 환경은 frame-ancestors / X-Frame-Options 둘 다 정리. frame-ancestors 가 신표준이고 우선순위. X-Frame-Options 는 비워두거나 일치시킨다. ALLOW-FROM 은 신뢰 안 된다.
- 운영에서는 Admin UI 보다 realm.json / Helm values 를 single source of truth 로. UI 변경은 다음 deploy 에서 날아갈 가능성 상존. GitOps 환경이면 더더욱.
- frame-ancestors 와 frame-src 의 방향성을 헷갈리지 말 것. 임베드 당하는 쪽이 통제하는 게 frame-ancestors. 임베드 하는 쪽이 통제하는 게 frame-src. 둘 다 CSP 이지만 역할이 정반대.
다음 단계
- iframe 안에서 Keycloak 로그인 후 토큰 전파가 정상인지 (cross-origin cookie / SameSite 정책 확인).
- CSP 의 frame-src 가 shell 쪽에도 정확히 설정되어 있는지 점검.
- Content-Security-Policy-Report-Only 로 staging 에서 미리 위반 사례를 수집해두면 production 사고가 줄어든다.
참고
- MDN frame-ancestors: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors
- MDN X-Frame-Options: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
- Keycloak Server Admin Guide – Threat Model Mitigation: https://www.keycloak.org/docs/latest/server_admin/
- Keycloak browserSecurityHeaders 옵션 목록: https://www.keycloak.org/server/all-config