브라우저 개인 정보 보호 프록시 및 IP 보호 설정은 Google Ad Manager에 대한 서드 파티 광고 요청에서 클라이언트 IP 주소를 마스킹할 수 있습니다. 마스킹된 IP 주소는 지역 타겟팅 정확성과 광고 관련성을 저하시킬 수 있습니다. 웹 서버 또는 콘텐츠 전송 네트워크(CDN)가 퍼스트 파티 맥락에서 사용자와 상호작용하므로 서버는 사용자의 마스킹되지 않은 IP 주소를 수신합니다.
게시자 제공 IP 기능을 사용하면 서버에서 사용자의 마스킹되지 않은 IPv4 또는 IPv6 주소를 캡처하고, 하이브리드 공개 키 암호화(HPKE)를 사용하여 주소를 암호화하고, 암호화된 페이로드를 클라이언트의 Google 게시자 태그(GPT)에 전달할 수 있습니다.
이 페이지에서는 게시자 제공 IP를 GPT와 통합하는 방법을 설명합니다.
서버 측 워크플로 구현
브라우저에서 사용자의 일반 텍스트 IP 주소가 서드 파티 스크립트에 노출되지 않도록 하려면 웹 서버 또는 CDN 에지 작업자에서 다음 단계를 수행하세요.
- 모든 IP 캡처를 수행합니다.
- 공개 키를 가져옵니다.
- HPKE 암호화를 수행합니다.
클라이언트 IP 주소 캡처
웹 서버 또는 CDN의 수신 페이지 요청에서 클라이언트의 마스킹되지 않은 IPv4 또는 IPv6 주소를 추출합니다.
- 직접 연결: 수신 HTTP 연결의 원격 소켓 주소를 읽습니다.
- CDN, 부하 분산기 또는 리버스 프록시 뒤:
X-Forwarded-For헤더 또는 CDN별 클라이언트 IP 헤더와 같은 신뢰할 수 있는 에지 프록시에서 클라이언트 IP 헤더를 읽습니다. 프록시 인프라에서 추가하거나 재정의하는 전달 헤더만 신뢰합니다.X-Forwarded-For헤더에 쉼표로 구분된 여러 주소가 포함된 경우 신뢰할 수 있는 에지 프록시가 추가한 클라이언트 IP를 선택합니다.
Ad Manager에서 지원하는 주소는 다음과 같습니다.
- IPv4(예:
203.0.113.42) - IPv6(예:
2001:db8::8a2e:370:7334)
포트 번호, 대괄호, CIDR 프리픽스 또는 주변 공백 없이 기본 IP 주소 문자열만 전달합니다.
공개 키 가져오기 및 캐시
Google은 활성 HPKE 암호화 키를 다음 엔드포인트에서 RFC 8037 JSON 웹 키 세트(JWKS)로 게시합니다.
https://www.gstatic.com/real-ip/ip_enc_key.json
이 엔드포인트에 대한 GET 요청은 JSON 객체를 반환하며, 이 객체의 keys 배열에는 단일 활성 키 객체가 포함됩니다. 해당 객체의 x 속성에 있는 키를 사용하여 클라이언트 IP 주소를 암호화합니다.
다음 예시에서는 공개 키를 가져오고 성공 시 x 속성을 추출하고 실패 시 예외를 발생시킵니다.
자바
private static final String KEY_ENDPOINT_URL = "https://www.gstatic.com/real-ip/ip_enc_key.json";
/**
* Fetches the active X25519 public key from Google's key endpoint.
*
* <p>In production, cache the returned public key in memory and refresh it at least once every 24
* hours rather than fetching it on every incoming request.
*
* @return The unpadded Base64URL-encoded 32-byte X25519 public key ('x').
*/
public static String fetchPublicKey() throws IOException, InterruptedException {
return fetchPublicKey(KEY_ENDPOINT_URL);
}
public static String fetchPublicKey(String endpointUrl) throws IOException, InterruptedException {
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(endpointUrl))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException("Failed to fetch public key, HTTP status: " + response.statusCode());
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
JsonArray keys = json.getAsJsonArray("keys");
if (keys == null || keys.isEmpty()) {
throw new IOException("No keys found in response from " + endpointUrl);
}
JsonObject keyObj = keys.get(0).getAsJsonObject();
if (!keyObj.has("x")) {
throw new IOException("Key payload missing 'x' property: " + keyObj);
}
return keyObj.get("x").getAsString();
}
Python
KEY_ENDPOINT_URL = "https://www.gstatic.com/real-ip/ip_enc_key.json"
def fetch_public_key(url: str = KEY_ENDPOINT_URL) -> str:
"""Fetches the active X25519 public key from Google's key endpoint.
In production, cache the returned public key in memory and refresh it at
least once every 24 hours rather than fetching it on every incoming request.
Args:
url: URL of the public key endpoint.
Returns:
The unpadded Base64URL-encoded 32-byte X25519 public key ('x').
"""
with urllib.request.urlopen(url, timeout=5) as response:
data = json.loads(response.read().decode("utf-8"))
keys = data.get("keys", [])
if not keys:
raise ValueError(f"No keys found in response from {url}")
key_payload = keys[0].get("x")
if not key_payload:
raise ValueError(f"Key payload missing 'x' property: {keys[0]}")
return key_payload
JavaScript
const KEY_ENDPOINT_URL = 'https://www.gstatic.com/real-ip/ip_enc_key.json';
/**
* Fetches the active X25519 public key from Google's key endpoint.
*
* In production, cache the returned public key in memory and refresh it at
* least once every 24 hours rather than fetching it on every incoming request.
*
* @param {string=} url URL of the public key endpoint.
* @return {!Promise<string>} The unpadded Base64URL-encoded 32-byte X25519
* public key ('x').
*/
async function fetchPublicKey(url = KEY_ENDPOINT_URL) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch public key, HTTP status: ${response.status}`,
);
}
const data = await response.json();
if (!data.keys || data.keys.length === 0) {
throw new Error(`No keys found in response from ${url}`);
}
const keyPayload = data.keys[0].x;
if (!keyPayload) {
throw new Error(
`Key payload missing 'x' property: ${JSON.stringify(data.keys[0])}`,
);
}
return keyPayload;
}
TypeScript
const KEY_ENDPOINT_URL = 'https://www.gstatic.com/real-ip/ip_enc_key.json';
interface JwkKey {
kty?: string;
crv?: string;
x?: string;
kid?: string;
}
interface KeyEndpointResponse {
keys?: JwkKey[];
}
/**
* Fetches the active X25519 public key from Google's key endpoint.
*
* In production, cache the returned public key in memory and refresh it at
* least once every 24 hours rather than fetching it on every incoming request.
*
* @param url URL of the public key endpoint.
* @return The unpadded Base64URL-encoded 32-byte X25519 public key ('x').
*/
export async function fetchPublicKey(
url: string = KEY_ENDPOINT_URL,
): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch public key, HTTP status: ${response.status}`,
);
}
const data = (await response.json()) as KeyEndpointResponse;
if (!data.keys || data.keys.length === 0) {
throw new Error(`No keys found in response from ${url}`);
}
const keyPayload = data.keys[0].x;
if (!keyPayload) {
throw new Error(
`Key payload missing 'x' property: ${JSON.stringify(data.keys[0])}`,
);
}
return keyPayload;
}
성공하면 응답에 x 속성이 있는 키 객체가 포함된 keys 배열이 포함됩니다.
{
"keys": [
{
"kty": "OKP",
"crv": "X25519",
"x": "7bws5TIHOUrcJNofEubW3DPW8zR9EtjFNLm_Grv98TA",
"kid": "EZMLFw"
}
]
}
다음 표에서는 keys 배열에 있는 키 객체의 속성을 설명합니다.
| 속성 | 설명 |
|---|---|
crv |
타원 곡선 식별자입니다. 항상 X25519입니다. |
kid |
패딩되지 않은 base64url로 인코딩된 키 식별자이며, Google에서 키를 순환할 때 변경됩니다. |
kty |
키 유형입니다. 항상 OKP(옥텟 키 쌍)입니다. |
x |
암호화에 사용되는 패딩되지 않은 base64url로 인코딩된 32바이트 원시 X25519 공개 키입니다. |
가져온 공개 키를 서버의 메모리에 캐시하고 최소 24시간마다 한 번씩 또는 HTTP Cache-Control 응답 헤더에 따라 더 빨리 키를 새로고침합니다. 수신되는 페이지 요청마다 공개 키를 가져오지 않습니다.
Google은 순환된 비공개 키를 보관합니다. 이전 24시간 동안 캐시된 공개 키로 암호화된 페이로드는 키 순환 중에 중단 없이 계속 복호화됩니다.
IP 주소 암호화
HPKE(RFC 9180)와 공개 키 가져오기 및 캐시에서 가져온 디코딩된 32바이트 X25519 공개 키 x를 사용하여 클라이언트 IP 주소 문자열의 UTF-8 바이트 표현을 암호화합니다.
다음 예시에서는 IP 주소 문자열을 암호화합니다.
자바
이 예시에서는 Tink(com.google.crypto.tink:tink 1.16.0 이상)를 사용합니다.
static {
try {
HybridConfig.register();
} catch (GeneralSecurityException e) {
throw new ExceptionInInitializerError(e);
}
}
/**
* Encrypts an IP address using HPKE (DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-256-GCM).
*
* @param pubXB64Url Base64URL-encoded 32-byte X25519 public key ('x').
* @param ipStr Plaintext IPv4 or IPv6 address string (for example, "203.0.113.42").
* @return Unpadded Base64URL-encoded payload (enc || ciphertext).
*/
@AccessesPartialKey
public static String encryptIp(String pubXB64Url, String ipStr) throws GeneralSecurityException {
// 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
byte[] pubBytes = Base64.getUrlDecoder().decode(pubXB64Url);
if (pubBytes.length != 32) {
throw new IllegalArgumentException(
"Public key must be exactly 32 bytes, got " + pubBytes.length);
}
// 2. Configure HPKE parameters (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
// AES_256_GCM, NO_PREFIX).
HpkeParameters parameters =
HpkeParameters.builder()
.setKemId(HpkeParameters.KemId.DHKEM_X25519_HKDF_SHA256)
.setKdfId(HpkeParameters.KdfId.HKDF_SHA256)
.setAeadId(HpkeParameters.AeadId.AES_256_GCM)
.setVariant(HpkeParameters.Variant.NO_PREFIX)
.build();
HpkePublicKey publicKey =
HpkePublicKey.create(parameters, Bytes.copyFrom(pubBytes), /* idRequirement= */ null);
// 3. Encrypt the UTF-8 IP address bytes. With Variant.NO_PREFIX, Tink
// outputs raw (enc || ciphertext_with_tag).
KeysetHandle handle =
KeysetHandle.newBuilder()
.addEntry(KeysetHandle.importKey(publicKey).withRandomId().makePrimary())
.build();
HybridEncrypt encryptor = handle.getPrimitive(RegistryConfiguration.get(), HybridEncrypt.class);
byte[] payload =
encryptor.encrypt(ipStr.getBytes(StandardCharsets.UTF_8), /* contextInfo= */ new byte[0]);
// 4. Return unpadded Base64URL-encoded payload.
return Base64.getUrlEncoder().withoutPadding().encodeToString(payload);
}
Python
이 예시에서는 pyhpke를 사용합니다.
def encrypt_ip(pub_x_b64url: str, ip_str: str) -> str:
"""Encrypts an IP address using HPKE (RFC 9180).
Args:
pub_x_b64url: Base64URL-encoded 32-byte X25519 public key ('x').
ip_str: Plaintext IPv4 or IPv6 address string (for example, "203.0.113.42").
Returns:
Unpadded Base64URL-encoded payload (enc || ciphertext).
"""
# 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
pub_bytes = base64.urlsafe_b64decode(
pub_x_b64url + "=" * (-len(pub_x_b64url) % 4)
)
if len(pub_bytes) != 32:
raise ValueError(
f"Public key must be exactly 32 bytes, got {len(pub_bytes)}"
)
# 2. Configure HPKE cipher suite (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
# AES256_GCM).
suite = CipherSuite.new(
KEMId.DHKEM_X25519_HKDF_SHA256, KDFId.HKDF_SHA256, AEADId.AES256_GCM
)
public_key = suite.kem.deserialize_public_key(pub_bytes)
# 3. Create a fresh sender context per encryption (you may cache 'suite' and
# 'public_key', but never reuse 'sender' across multiple seal() calls) and
# package (enc || ciphertext).
enc, sender = suite.create_sender_context(public_key)
ciphertext = sender.seal(ip_str.encode("utf-8"))
# 4. Return unpadded Base64URL-encoded payload.
return base64.urlsafe_b64encode(enc + ciphertext).decode("utf-8").rstrip("=")
JavaScript
이 예시에서는 @hpke/core를 사용합니다.
/**
* Encrypts an IP address using HPKE (RFC 9180).
*
* @param {string} pubXB64Url Base64URL-encoded 32-byte X25519 public key ('x').
* @param {string} ipStr Plaintext IPv4 or IPv6 address string (for example,
* "203.0.113.42").
* @return {!Promise<string>} Unpadded Base64URL-encoded payload
* (enc || ciphertext).
*/
async function encryptIp(pubXB64Url, ipStr) {
// 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
const pubBytes = Buffer.from(pubXB64Url, 'base64url');
if (pubBytes.length !== 32) {
throw new Error(
`Public key must be exactly 32 bytes, got ${pubBytes.length}`,
);
}
// 2. Configure HPKE cipher suite (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
// AES_256_GCM).
const suite = new CipherSuite({
kem: new DhkemX25519HkdfSha256(),
kdf: new HkdfSha256(),
aead: new Aes256Gcm(),
});
const rawPubKey = new Uint8Array(pubBytes).buffer;
const publicKey = await suite.kem.importKey('raw', rawPubKey, true);
// 3. Encrypt the UTF-8 IP address bytes and package (enc || ciphertext).
const plaintext = new Uint8Array(Buffer.from(ipStr, 'utf8')).buffer;
const {enc, ct} = await suite.seal(
{recipientPublicKey: publicKey},
plaintext,
);
// 4. Return unpadded Base64URL-encoded payload.
return Buffer.concat([Buffer.from(enc), Buffer.from(ct)]).toString(
'base64url',
);
}
TypeScript
이 예시에서는 @hpke/core를 사용합니다.
/**
* Encrypts an IP address using HPKE (RFC 9180).
*
* @param pubXB64Url Base64URL-encoded 32-byte X25519 public key ('x').
* @param ipStr Plaintext IPv4 or IPv6 address string (for example,
* "203.0.113.42").
* @return Unpadded Base64URL-encoded payload (enc || ciphertext).
*/
export async function encryptIp(
pubXB64Url: string,
ipStr: string,
): Promise<string> {
// 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
const pubBytes = Buffer.from(pubXB64Url, 'base64url');
if (pubBytes.length !== 32) {
throw new Error(
`Public key must be exactly 32 bytes, got ${pubBytes.length}`,
);
}
// 2. Configure HPKE cipher suite (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
// AES_256_GCM).
const suite = new CipherSuite({
kem: new DhkemX25519HkdfSha256(),
kdf: new HkdfSha256(),
aead: new Aes256Gcm(),
});
const rawPubKey = new Uint8Array(pubBytes).buffer;
const publicKey = await suite.kem.importKey('raw', rawPubKey, true);
// 3. Encrypt the UTF-8 IP address bytes and package (enc || ciphertext).
const plaintext = new Uint8Array(Buffer.from(ipStr, 'utf8')).buffer;
const {enc, ct} = await suite.seal(
{recipientPublicKey: publicKey},
plaintext,
);
// 4. Return unpadded Base64URL-encoded payload.
return Buffer.concat([Buffer.from(enc), Buffer.from(ct)]).toString(
'base64url',
);
}
다음 암호화 스위트 및 매개변수를 사용하여 HPKE 라이브러리를 구성합니다.
| 매개변수 | 값 |
|---|---|
| 키 캡슐화 메커니즘(KEM) | DHKEM(X25519, HKDF-SHA256)(0x0020) |
| 키 파생 함수(KDF) | HKDF-SHA256(0x0001) |
| 연관 데이터로 인증 암호화(AEAD) | AES-256-GCM(0x0002) |
| 모드 | 기본(0x00) |
애플리케이션 정보(info) |
비어 있음("") |
연결된 데이터(aad) |
비어 있음("") |
암호화된 출력의 형식을 32바이트 캡슐화된 임시 공개 키, enc, 16바이트 GCM 인증 태그를 포함한 AEAD 암호문(Tink 또는 커스텀 헤더 프리픽스 없음)의 원시 연결로 지정합니다. 결과를 패딩되지 않은 base64url 문자열로 인코딩합니다.
새로운 임시 키 쌍을 사용하여 각 페이로드를 독립형 단일 샷 HPKE 메시지로 암호화합니다. HPKE 라이브러리에서 스테이트풀(Stateful) 발신자 컨텍스트를 사용하는 경우 여러 메시지에서 재사용하는 대신 각 암호화에 대해 새 컨텍스트를 만듭니다. 페이지 요청당 클라이언트 IP 주소를 암호화하거나 짧은 TTL(수명) 동안 IP 주소당 결과 base64url 암호문 문자열을 캐시합니다.
클라이언트 측 워크플로 구현
서버에서 암호화된 base64url IP 페이로드를 생성한 후 광고를 요청하기 전에 클라이언트의 GPT에 페이로드를 전달합니다.
암호화된 IP를 GPT에 전달
PageSettingsConfig의 ipAddress 속성을 사용하여 암호화된 IP 문자열을 googletag.setConfig 메서드에 전달합니다.
다음 예에서는 서비스를 사용 설정하고 광고 슬롯을 표시하기 전에 서버에서 생성된 암호화된 IP 페이로드를 googletag.setConfig 메서드에 삽입합니다.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Publisher-provided IP example</title>
<script src="https://securepubads.g.doubleclick.net/tag/js/gpt.js" crossorigin="anonymous" async></script>
<script>
window.googletag = window.googletag || {cmd: []};
// Inject the base64url-encoded encrypted IP generated by your server.
const encryptedClientIp = 'ENCRYPTED_IP_PAYLOAD';
googletag.cmd.push(function() {
// Configure the encrypted IP before enabling services.
googletag.setConfig({
ipAddress: encryptedClientIp,
});
googletag
.defineSlot('/6355419/Travel/Europe/France/Paris', [300, 250], 'banner-ad')
.addService(googletag.pubads());
googletag.enableServices();
});
</script>
</head>
<body>
<div id="banner-ad" style="width: 300px; height: 250px;"></div>
<script>
googletag.cmd.push(function() {
googletag.display('banner-ad');
});
</script>
</body>
</html>ENCRYPTED_IP_PAYLOAD을 서버에서 생성하는 패딩되지 않은 base64url로 인코딩된 문자열로 바꿉니다.
페이지에서 이전에 구성된 IP 주소를 삭제하려면 ipAddress 속성을 null 값으로 설정합니다.
googletag.setConfig({ ipAddress: null });
통합 확인
GPT가 암호화된 IP 페이로드를 전송하고 Ad Manager가 페이로드를 복호화하는지 확인하려면 브라우저의 개발자 도구와 Google 게시자 콘솔을 사용하세요.
브라우저 개발자 도구
- 브라우저의 개발자 도구를 엽니다.
- 네트워크 탭을 선택합니다.
/gampad/ads에 대한 광고 요청을 필터링합니다.- 요청 URL에 비어 있지 않은
ips쿼리 매개변수가 포함되어 있는지 확인합니다. GPT는 광고 요청을 전송하기 전에ipAddress값을 추가로 인코딩하므로ips매개변수가 암호화된 문자열과 정확히 일치하지 않습니다.
Google 게시자 콘솔
- 콘솔을 열려면 Google 게시자 콘솔 가이드의 단계를 따르세요.
- 페이지 요청 탭을 선택합니다.
ipAddress경고가 표시되지 않는지 확인합니다. 페이로드에 문제가 있는 경우 콘솔에 다음 경고 중 하나가 로깅됩니다.ipAddress속성에 전달된 값이 67~124자(영문 기준) 사이의 유효한 base64 또는 base64url 문자열이 아닌 경우 GPT는 값을 무시하고 광고 요청에서ips매개변수를 생략하며 다음 경고를 기록합니다.Invalid value encountered when calling: googletag.setConfig: {"ipAddress":"..."}. (string) ... was found at PageSettingsConfig.ipAddress. Invalid IP address format.Ad Manager에서 제공된 IP 페이로드를 복호화할 수 없거나 복호화된 일반 텍스트가 유효한 IPv4 또는 IPv6 주소가 아닌 경우 콘솔은 다음 경고를 로깅하고 네트워크 요청 IP 주소로 대체합니다.
Decryption failed on the provided encrypted client IP (supplied via googletag.setConfig({ipAddress: ...})).