diff --git a/src/main/java/com/eactive/apim/portal/common/internal/InternalApiTokenService.java b/src/main/java/com/eactive/apim/portal/common/internal/InternalApiTokenService.java
new file mode 100644
index 0000000..cd7f516
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/common/internal/InternalApiTokenService.java
@@ -0,0 +1,156 @@
+package com.eactive.apim.portal.common.internal;
+
+import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * 내부 API(admin → portal) 공유 토큰 관리.
+ *
+ *
포탈의 {@code /internal/**} 는 CSRF 예외 경로다. CSRF 토큰이 없는 대신 커스텀 헤더를 요구해
+ * 브라우저발 cross-site 위조(form POST 는 커스텀 헤더를 붙일 수 없다)를 원천 차단한다.
+ * 헤더명과 토큰값은 PTL_PROPERTY {@code Portal} 그룹으로 관리한다 — portal·admin 이 동일 DB 를
+ * 공유하므로 별도 배포/설정 동기화 없이 같은 값을 읽는다.
+ *
+ *
+ * PTL_PROPERTY (group = Portal)
+ * | 키 | 기본값 | 비고 |
+ * | {@code internal.api.header-name} | {@code X-Internal-Token} | 요청 헤더명 |
+ * | {@code internal.api.token} | 기동 시 난수 생성 | 256bit SecureRandom, base64url |
+ *
+ *
+ * 생성 주체
+ * 토큰을 생성하는 쪽은 포탈 한 곳이다({@code ensureXxx}, 기동 시 1회). admin 은 읽기 전용
+ * ({@code findXxx})으로만 접근해, 포탈보다 먼저 기동하더라도 엉뚱한 값을 선점 저장하지 않는다.
+ *
+ * 토큰 교체
+ * PTL_PROPERTY 값을 수정하면 즉시 반영된다(양쪽 모두 호출 시점에 조회). {@code ptl_property} 는 2차 캐시
+ * 대상이므로 값 변경 후 첫 호출이 실패하면 각 인스턴스 재기동으로 캐시를 비운다.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class InternalApiTokenService {
+
+ public static final String PROP_GROUP = "Portal";
+ public static final String PROP_HEADER_NAME = "internal.api.header-name";
+ public static final String PROP_TOKEN = "internal.api.token";
+
+ /** 헤더명 기본값 — 프로퍼티가 없을 때 portal·admin 이 동일하게 사용한다. */
+ public static final String DEFAULT_HEADER_NAME = "X-Internal-Token";
+
+ static final String HEADER_NAME_DESCRIPTION =
+ "내부 API(admin → portal /internal/**) 인증 헤더명. CSRF 예외 경로의 위조 방지용";
+ static final String TOKEN_DESCRIPTION =
+ "내부 API(admin → portal /internal/**) 공유 토큰. 최초 기동 시 자동 생성되며, 교체 시 값만 바꾸면 됨";
+
+ /** 토큰 엔트로피(바이트). base64url 인코딩 시 43자. */
+ private static final int TOKEN_BYTES = 32;
+
+ private final PortalPropertyService portalPropertyService;
+
+ private final SecureRandom secureRandom = new SecureRandom();
+
+ /**
+ * 이 JVM 이 생성한 토큰. DB 저장이 실패해도(그룹 미존재 등) 호출마다 값이 달라지지 않도록 붙잡아 둔다.
+ * — 값이 흔들리면 "가끔 되고 가끔 안 되는" 형태로 증상이 숨는다.
+ */
+ private final AtomicReference generatedToken = new AtomicReference<>();
+
+ /** 헤더명 조회 — 없으면 기본값으로 생성한다. (포탈 전용) */
+ public String ensureHeaderName() {
+ String headerName = getOrCreate(PROP_HEADER_NAME, DEFAULT_HEADER_NAME, HEADER_NAME_DESCRIPTION);
+ return isBlank(headerName) ? DEFAULT_HEADER_NAME : headerName.trim();
+ }
+
+ /** 토큰 조회 — 없으면 난수를 생성해 저장한다. (포탈 전용) */
+ public String ensureToken() {
+ String token = getOrCreate(PROP_TOKEN, localToken(), TOKEN_DESCRIPTION);
+ return isBlank(token) ? null : token.trim();
+ }
+
+ /** 헤더명 조회(읽기 전용) — 없으면 기본값. 생성하지 않는다. (admin 용) */
+ public String findHeaderName() {
+ String headerName = read(PROP_HEADER_NAME);
+ return isBlank(headerName) ? DEFAULT_HEADER_NAME : headerName.trim();
+ }
+
+ /** 토큰 조회(읽기 전용) — 없으면 {@code null}. 생성하지 않는다. (admin 용) */
+ public String findToken() {
+ String token = read(PROP_TOKEN);
+ return isBlank(token) ? null : token.trim();
+ }
+
+ /**
+ * 요청이 제시한 토큰이 유효한지 검사한다. 값 비교는 타이밍 공격을 피해 상수 시간으로 수행한다.
+ * 기대 토큰을 확보하지 못하면 거부한다(fail-closed).
+ */
+ public boolean matches(String presented) {
+ String expected = ensureToken();
+ if (expected == null) {
+ log.error("내부 API 토큰({}/{})을 확보하지 못해 요청을 거부합니다.", PROP_GROUP, PROP_TOKEN);
+ return false;
+ }
+ if (isBlank(presented)) {
+ return false;
+ }
+ return MessageDigest.isEqual(
+ presented.trim().getBytes(StandardCharsets.UTF_8),
+ expected.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /** 기동 시 1회 호출 — 헤더명/토큰 프로퍼티를 미리 만들어 둔다. (포탈 전용) */
+ public void initialize() {
+ String headerName = ensureHeaderName();
+ String token = ensureToken();
+ if (token == null) {
+ log.error("내부 API 토큰 초기화 실패 - PTL_PROPERTY 그룹 '{}' 존재 여부를 확인하세요.", PROP_GROUP);
+ return;
+ }
+ // 토큰 값은 로그에 남기지 않는다.
+ log.info("내부 API 토큰 초기화 완료 - header: {}, tokenLength: {}", headerName, token.length());
+ }
+
+ private String getOrCreate(String propertyName, String defaultValue, String description) {
+ try {
+ return portalPropertyService.getOrCreateProperty(PROP_GROUP, propertyName, defaultValue, description);
+ } catch (Exception e) {
+ log.warn("내부 API 프로퍼티 조회 실패 - {}/{}", PROP_GROUP, propertyName, e);
+ return defaultValue;
+ }
+ }
+
+ private String read(String propertyName) {
+ try {
+ Map properties = portalPropertyService.getPortalPropertiesAsMap(PROP_GROUP);
+ return properties.get(propertyName);
+ } catch (Exception e) {
+ log.warn("내부 API 프로퍼티 조회 실패 - {}/{}", PROP_GROUP, propertyName, e);
+ return null;
+ }
+ }
+
+ /** JVM 당 1회만 생성되는 난수 토큰 (DB 에 값이 없을 때의 기본값). */
+ private String localToken() {
+ String token = generatedToken.get();
+ if (token != null) {
+ return token;
+ }
+ byte[] bytes = new byte[TOKEN_BYTES];
+ secureRandom.nextBytes(bytes);
+ String candidate = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ return generatedToken.compareAndSet(null, candidate) ? candidate : generatedToken.get();
+ }
+
+ private static boolean isBlank(String s) {
+ return s == null || s.trim().isEmpty();
+ }
+}