
⟡ 인트로
OKTA로 인증 방식을 전환하면서 기존 SSO 인증서가 담당하던 내/외부망 구분 기능이 사라졌다.
처음에는 HTTP Referer 헤더를 이용해 내부망 사용자를 판별하려 했으나,
즐겨찾기 접속·OKTA 리다이렉트·forward 등 실제 운영 환경에서 Referer가 소실되는 경우가 너무 많아
정상 내부망 사용자까지 차단되는 문제가 발생했다.
결국 Stateless한 Referer를 버리고, 서버가 상태를 기억하는 세션 플래그(Session Flag) 방식으로 전환하여 문제를 해결했다. 이 글은 그 과정에서 Referer가 무엇이고 왜 접근 제어의 신뢰 기반이 될 수 없는지를 정리한 기록이다.
⟡ 개발환경
AS-IS TO-BE
| SSO PKI 인증서 기반 내/외부 구분 | OKTA SAML 인증 기반 |
| 인증서 유무로 VDI 여부 판단 | 세션 플래그(INTERNAL_VERIFIED)로 판단 |
| UserInterceptor (비활성, 데드코드) | AuthenticationInterceptor (활성) |
| Java 1.7 / Spring Framework 4.x | Java 17 / Spring Boot 3.x |
⟡ 문제 상황

기존 방식이 사라진 이유
레거시 코드의 인터셉터에는 SSO 인증서를 검증하는 로직이 있었다.
사용자 PC(또는 VDI)에 설치된 사내 인증서 모듈을 호출해서 VDI 여부를 판별했던 것이다.
VDI 사용자 → 인증서 모듈 실행 성공 → SSO 통과 ✅
외부 사용자 → PC에 사내 인증서 없음 → 모듈 로드 실패 → 차단 ❌
브라우저가 보내는 헤더(텍스트)를 믿은 게 아니라, PC 자체에 설치된 암호화된 인증서라는 물리적 증거를 검사했기 때문에
망 분리가 가능했다. 하지만 OKTA로 전환되면서 이 인증서 검증 로직이 사라졌고,
애플리케이션 입장에서는 "이 사람이 VDI 안에 있는가"를 판단할 방법이 없어진 상태가 됐다.
왜 Referer를 먼저 시도했는가
팀 내에서 최초로 나온 아이디어는 HTTP Referer 헤더를 이용하는 것이었다.
요구사항은 아래 두 조건 중 하나를 만족하면 내부 사용자로 보자는 것이었다.
- Referer 헤더에 내부망 포털 도메인([내부망 포털])이 포함되어 있음
- 특정 관문 경로(/main.do)로 직접 접근함
코드로 표현하면 아래와 같다.
private boolean isInternalAccess(String referer, String requestPath) {
// 조건 A: Referer 체크
if (referer != null && referer.contains("[내부망 포털]")) {
return true;
}
// 조건 B: 관문 경로 체크
if (requestPath != null && requestPath.startsWith("/main")) {
return true;
}
return false;
}
겉보기에는 합리적인 것 같았다. 하지만 실제 배포 테스트를 해보니 두 가지 구조적 문제가 드러났다.
⟡ 1번째 시도: Referer 헤더 기반 내/외부 판단 (실패)

시도한 이유
내부망 포털에서 링크를 클릭해 들어오는 경우, 브라우저가 자동으로 출발지 URL을 Referer 헤더에 담아 전송한다.
이 헤더를 인터셉터에서 읽어서 내부망 도메인 포함 여부를 판별하는 방식이다.
문제점 1: 즐겨찾기·주소창 직접 입력 시 Referer는 null
VDI 사용자라도 크롬을 새로 켜고 주소창에 직접 URL을 입력하거나 즐겨찾기로 접속하면,
브라우저는 Referer 헤더를 아예 보내지 않는다. 서버에 도착하는 HTTP 요청을 보면 이렇다.
GET /main.do HTTP/1.1
Host: [내부서버]
User-Agent: Mozilla/5.0 ...
Cookie: JSESSIONID=...
(Referer 헤더 없음)
인터셉터 입장에서는 Referer가 null이므로 "출발지가 없다 → 외부망이다"라고 오판하게 된다.
정상 VDI 사용자가 차단되는 것이다.
문제점 2: forward 또는 OKTA 리다이렉트 시 Referer 증발
/main.do 조건을 통과해 세션 검증 없이 인터셉터를 통과했다고 하자.
이후 forward:/view/p/login.do로 내부 포워딩되는 순간, 서버 내부에서 새로운 요청이 생성되기 때문에
Referer가 비어버린다. OKTA 302 리다이렉트 이후에도 동일한 현상이 발생한다.
/main.do (통과) → forward → /view/p/login.do
→ 인터셉터 재진입
→ Referer: null
→ 차단 ❌
어느 레이어에서 검사해도 결과는 동일하다.
검사 위치 실행 코드 결과
| Filter | request.getHeader("Referer") | null → 차단 |
| Interceptor | request.getHeader("Referer") | null → 차단 |
| Controller | request.getHeader("Referer") | null → 차단 |
| JSP/JS | document.referrer | "" → 차단 |
핵심은 코드의 위치 문제가 아니라, 데이터의 부재 문제다.
브라우저가 제공하지 않는 정보는 서버의 어떤 레이어에서도 만들어낼 수 없다.
배운 점
Referer는 Stateless 방식이다. "지금 이 순간의 요청"이 어디서 왔는지만 알 뿐"
이 사람이 아까 어떤 경로로 들어왔는지"는 기억하지 못한다.
접근 제어처럼 연속적인 상태를 기억해야 하는 로직에는 태생적으로 부적합하다.
⟡ 최종 해결: 세션 플래그(Session Flag) 방식

Referer를 버리고 세션(Session)으로 관점을 바꿨다.
세션은 브라우저를 닫기 전까지 상태를 기억하는 Stateful한 저장소다.
아이디어는 단순하다.
/main.do 관문을 통과한 사람의 세션에 "내부망 인증 완료" 도장을 찍어두고,
이후 모든 요청에서는 Referer를 보지 않고 이 도장의 유무만 확인한다.
핵심 설계
1. 상수 정의 — 관문 URL 변경 시 이 한 곳만 수정하면 된다.
public final static class NetworkAccess {
public static final String GATEWAY_PATH = "/main.do";
public static final String OKTA_SAML_CALLBACK_PATH = "/login/okta-saml-check.do";
public static final String SESSION_KEY_INTERNAL_VERIFIED = "INTERNAL_VERIFIED";
}
2. 관문 컨트롤러 — /main.do 진입 시 세션에 플래그를 세팅한다.
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
@Controller
public class MainRedirectController {
@RequestMapping(NetworkAccess.GATEWAY_PATH)
public ModelAndView redirectMainToLogin(HttpServletRequest request) {
// 내부망 진입 도장 찍기
request.getSession().setAttribute(
NetworkAccess.SESSION_KEY_INTERNAL_VERIFIED,
Boolean.TRUE
);
return new ModelAndView("forward:/view/p/login.do");
}
}
3. 인터셉터 판단 로직 — Referer 대신 세션 플래그만 본다.
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
private boolean isInternalAccess(HttpServletRequest request, String requestPath) {
// 1. /main.do 관문 진입: 무조건 통과 (세팅은 컨트롤러에서)
if (requestPath != null
&& requestPath.indexOf(NetworkAccess.GATEWAY_PATH) > -1) {
return true;
}
// 2. OKTA SAML 콜백: 세션이 재생성되므로 이 시점에 플래그 재세팅
if (requestPath != null
&& requestPath.indexOf(NetworkAccess.OKTA_SAML_CALLBACK_PATH) > -1) {
request.getSession().setAttribute(
NetworkAccess.SESSION_KEY_INTERNAL_VERIFIED,
Boolean.TRUE
);
return true;
}
// 3. 그 외 모든 요청: 세션 플래그 유무만 확인
HttpSession session = request.getSession(false);
return session != null
&& session.getAttribute(NetworkAccess.SESSION_KEY_INTERNAL_VERIFIED) != null;
}
처리 흐름
VDI 사용자 → /main.do 접속
→ 세션에 INTERNAL_VERIFIED=true 세팅
→ OKTA 인증 완료 (세션 재생성 시 콜백에서 재세팅)
→ 이후 모든 요청: 세션 플래그 확인 → 통과 ✅
외부 사용자 → /view/p/login.do 직접 접속
→ 세션 플래그 없음
→ external-notice.jsp 차단 화면 ❌
왜 OKTA 콜백에서 플래그를 재세팅해야 하는가
OKTA SAML 인증이 완료되면 Session Fixation 공격 방지를 위해 세션 ID가 교체된다.
기존 세션에 저장해둔 INTERNAL_VERIFIED 플래그도 함께 사라지게 된다.
이를 방치하면 로그인 직후 AJAX 요청이 전부 차단된다.
/login/okta-saml-check.do 콜백 진입 시점에 플래그를 재세팅하면 이 문제를 방어할 수 있다.

⟡ 배운 점
- Referer는 "지금"만 알고 세션은 "흐름 전체"를 기억한다.
접근 제어처럼 요청 간의 맥락이 필요한 로직은 Stateless한 헤더가 아니라
Stateful한 세션을 신뢰 기반으로 삼아야 한다. - 데이터의 부재는 코드의 위치로 해결할 수 없다.
Referer 검사를 Filter·Interceptor·Controller·JSP 어디로 옮겨도 결과는 동일하다.
"어디서 검사하는가"가 아니라 "검사할 값이 존재하는가"의 문제이기 때문이다. - 완벽한 망분리를 원한다면 클라이언트 IP 화이트리스트나 인프라 레벨 정책이 유일한 해답이다.
브라우저가 선택적으로 전송하는 헤더(Referer)에 의존하는 방식은 언제든 우회될 수 있다.
세션 플래그 방식은 현실적인 절충안이지, 완벽한 보안 솔루션이 아니다.