AI APIへ個人情報を送る前に確認すること|PIIマスキングをTypeScriptで実装

OpenAI APIやClaude API、Gemini APIを使って問い合わせ要約・メール作成・RAG・カスタマーサポートなどを実装すると、ユーザーの氏名・メールアドレス・電話番号・住所といった個人情報がプロンプトへ含まれることがあります。「APIだからChatGPTの画面へ入力するより安全」「APIに送ったデータは保存されない」と考えて、そのまま送信するのは危険です。

AI APIでは、事業者・契約・使用するエンドポイント・機能によってデータの保存期間や取り扱いが異なります。さらに、自社のアプリケーションログ・エラー監視・RAGデータベース・Prompt Caching・外部ツールなど、AIモデル以外の場所にも個人情報が残る可能性があります。そのため、個人情報を扱うAIアプリでは、最初に「そもそもモデルへ送る必要がある情報なのか」を確認し、不要であれば削除します。必要な場合でも、氏名を<PERSON_1>、メールアドレスを<EMAIL_1>のようなプレースホルダーへ置き換えてからAI APIへ送るPIIマスキングを検討します。

この記事では、AI APIへ個人情報を送る前に確認したいポイントと、TypeScriptでPIIマスキングを実装する方法を解説します。

スポンサーリンク
  1. PIIとは
  2. 最も重要なのはマスキングよりデータ最小化
  3. OpenAI APIは学習に使われるのか
  4. OpenAIではstore:falseも確認する
  5. Claude APIのデータ保持も機能ごとに確認する
  6. Geminiは無料APIと有料APIを区別する
  7. Geminiのstore:falseだけではすべて消えない
  8. 「クラウドへ送ったら必ず第三者提供」とは限らない
  9. マスキングと匿名化は同じではない
  10. 正規表現だけで全PIIを検出することはできない
  11. TypeScriptでPIIマスキングを実装する
  12. 同じ値には同じプレースホルダーを割り当てる
  13. メールアドレスをマスキングする
  14. 電話番号をマスキングする
  15. 郵便番号だけを消しても住所は残る
  16. IPアドレスも必要に応じてマスキングする
  17. クレジットカードらしい番号はLuhnチェックする
  18. すべてをまとめたmaskPii関数
  19. 氏名はDBのフィールド段階で除去する
  20. 自由入力の人名や住所はNERを検討する
  21. Custom Termsで既知の個人情報を確実に消す
  22. 名前専用のプレースホルダーを用意する
  23. プレースホルダーを元に戻す必要があるか確認する
  24. 対応表をログへ出してはいけない
  25. APIリクエストを丸ごとログへ残さない
  26. エラーログにも個人情報が残る
  27. RAGでは検索チャンクにもPIIが含まれる
  28. Prompt Cachingにもマスキング後の文章を入れる
  29. File APIにもPIIが残る
  30. 画像にも個人情報が含まれる
  31. Tool Calling経由の送信先も確認する
  32. JSON Schemaへ個人情報を埋め込まない
  33. マスキング後にAI APIへ送る
  34. AIの回答もPIIチェックする
  35. APIキーやパスワードも一緒に検出する
  36. マスキング処理自体をテストする
  37. PrecisionとRecallの両方を見る
  38. 検出漏れを前提に設計する
  39. PIIマスキングを行う場所
  40. AI Gatewayでマスキングを強制する
  41. PIIが必要な処理まで全部マスクしない
  42. マスキングすると品質が落ちるケースもある
  43. 「AIに必要な粒度」まで情報を粗くする
  44. 本番導入前に確認したいこと
  45. よくある質問
  46. まとめ

PIIとは

PIIはPersonally Identifiable Informationの略で、個人を識別できる情報を表すために使われる言葉です。日本の個人情報保護法では「個人情報」を、生存する個人に関する情報で、氏名・生年月日などによって特定の個人を識別できるものや、個人識別符号を含むものなどと定義しています。氏名や住所だけでなく、他の情報と容易に照合することで個人を識別できる情報も対象になり得ます。個人情報保護委員会は、メールアドレスについても、ユーザー名やドメインから個人を識別できる場合や、他の情報と容易に照合して個人を識別できる場合には個人情報に該当すると説明しています。

AI APIへ送るデータでは、氏名だけを探せばよいわけではありません。たとえば次の問い合わせを考えます。

問い合わせ本文(変換前)
山田太郎です。

東京都○○区○○1-2-3に住んでいます。
注文番号A12345の商品が届きません。

連絡先:
taro.yamada@example.com
090-1234-5678

商品の配送状況を要約するだけなら、AIに山田太郎という本名・メールアドレス・電話番号まで知らせる必要はない可能性があります。次のように変換しても意味を保てます。

問い合わせ本文(変換後)
<PERSON_1>です。

<ADDRESS_1>に住んでいます。
注文番号A12345の商品が届きません。

連絡先:
<EMAIL_1>
<PHONE_1>

これがPIIマスキングの基本です。

最も重要なのはマスキングよりデータ最小化

PIIマスキングを実装する前に確認したいのが、「この情報をAIへ送る必要があるか」です。不要な情報なら、置換するより最初から送らないほうが安全です。たとえば問い合わせテーブルが次の構造だったとします。

SupportTicket型
type SupportTicket = {
  customerId: string;
  customerName: string;
  email: string;
  phone: string;
  address: string;
  subject: string;
  message: string;
};

このオブジェクトをそのままJSON化してAIへ送る実装は避けます。

NG: オブジェクト全体を送信
const prompt = JSON.stringify(ticket);

await callAi(prompt);

「問い合わせ本文のカテゴリ分類」が目的なら、必要なのはsubjectmessageだけかもしれません。

OK: 必要な項目だけ抽出
const prompt = `
件名:
${ticket.subject}

問い合わせ:
${ticket.message}
`.trim();

APIへ送信しなかった情報は、AI事業者側で保存されることも、回答へ再出力されることもありません。PII対策では、次の順番が重要です。

PII対策の順番
必要な情報だけを選ぶ
  ↓
残った文章からPIIを検出する
  ↓
マスキングする
  ↓
AI APIへ送る

OpenAI APIは学習に使われるのか

2026年8月時点のOpenAI APIでは、Chat CompletionsやResponses APIなどのAPIデータは、標準ではモデル学習に使用されません。ただし、「学習に使われない」と「保存されない」は別の話です。OpenAIでは標準でAPI利用に対するAbuse Monitoring Logsが生成され、プロンプトやレスポンスなどのCustomer Contentが最大30日保持される場合があります。対象となる顧客はZero Data RetentionやModified Abuse Monitoringを申請できます。

さらに、使用するAPI機能によってApplication Stateの保存方法も違います。たとえばResponses APIではApplication Stateが標準で30日保持されます。Files・Vector Stores・Conversationsなど、明示的に保存する機能では削除するまでデータが残るものもあります。したがって、次のように書くだけで「データが一切保存されない」と考えてはいけません。

データが自動的に消えるわけではない例
await openai.responses.create({
  model: process.env.OPENAI_MODEL!,
  input: maskedPrompt,
});

データ保持を抑えたい用途では、利用しているAPI・機能ごとのData Controlsを確認する必要があります。

OpenAIではstore:falseも確認する

Responses APIを使う場合は、会話状態をOpenAI側へ残す必要があるのか確認します。アプリケーション側で会話履歴を管理しており、Responses API側へ状態を保存する必要がない場合は、store: falseを使用できます。

store: false の指定
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const response = await openai.responses.create({
  model: process.env.OPENAI_MODEL!,
  input: maskedPrompt,
  store: false,
});

ただし、store: falseはAbuse Monitoring Logsそのものを無効にする設定ではありません。OpenAIではApplication StateとAbuse Monitoring Logsが別に扱われており、標準のAbuse Monitoring Logsは最大30日保持されます。ZDRが有効な組織ではResponses APIのstoreは常にfalseとして扱われます。「store: falseだからZero Data Retentionと同じ」という理解は避ける必要があります。

Claude APIのデータ保持も機能ごとに確認する

Anthropicの2026年8月時点のClaude APIドキュメントでは、保持したデータを明示的な許可なしにモデル学習へ使用しないと説明されています。また、技術的に保存を必要としない機能では会話内容を標準で保持しない方針ですが、一部のCovered Modelsなどには30日保持が必要な例外があります。ZDR契約では、対応機能についてAPIレスポンスを返した後に顧客のプロンプトやレスポンスを保存しません。TypeScriptから呼び出す基本的な実装は【TypeScript】Claude API入門で解説しています。

ただし、ZDRがClaudeのすべての機能に自動適用されるわけではありません。Managed Agentsのように状態を保存する機能、第三者インテグレーション、一部モデルなどは対象外です。Anthropic自身も、最新の対象製品・機能について契約条件または担当者へ確認するよう案内しています。個人情報を扱う場合は、「Claude APIだから大丈夫」ではなく、どのモデルか・どのAPI機能か・どの契約か・外部ツールを使うかまで確認する必要があります。

Geminiは無料APIと有料APIを区別する

Gemini APIでは特に注意が必要です。2026年8月時点のGemini API追加利用規約では、Unpaid Servicesについて、入力したコンテンツや生成された回答をGoogleの製品・サービスや機械学習技術の改善・開発に利用する場合があり、人間のレビュアーがAPI入力・出力を確認する場合があると説明されています。GoogleはUnpaid Servicesについて、機密情報・センシティブ情報・個人情報を送信しないよう明示しています。

一方、課金が有効なCloud Projectを通じてGemini APIのPaid Servicesを使用する場合、Googleはプロンプトやレスポンスを製品改善には使用しません。ただしPaid Servicesでも、不正利用検知などを目的としてプロンプトとレスポンスが限定期間ログへ保存される場合があります。個人情報を扱う業務アプリでGeminiを利用する場合は、「Gemini APIを使っている」だけではなく、そのリクエストがPaid Servicesとして処理されているかまで確認する必要があります。

Geminiのstore:falseだけではすべて消えない

GeminiのInteractions APIでは会話を継続するための状態保存が標準で有効です。Zero Data Retentionを目指す場合は、store: falseを明示する必要があります。ただし、これもすべての保存を止める万能なフラグではありません。GeminiではGrounding with Google SearchやGoogle Mapsを利用すると、プロンプト・コンテキスト・生成結果が30日保存され、この保存を無効化できません。File APIへアップロードしたファイルは削除または期限切れまで保存され、Explicit Context Cachingも設定したTTLまでデータを保存します。したがって、個人情報を扱う場合はモデル呼び出しだけではなく、Interactions・Files・Context Cache・Search Grounding・Maps Groundingといった関連機能も含めてデータフローを確認します。

「クラウドへ送ったら必ず第三者提供」とは限らない

日本の個人情報保護法との関係では、クラウド事業者へデータを送ったという事実だけから、必ず「第三者提供」と判断できるわけではありません。個人情報保護委員会は、クラウド事業者が個人データを取り扱うことになっているかどうかが判断基準になると説明しています。契約上、クラウド事業者が保存された個人データを取り扱わず、適切なアクセス制御も行われているようなケースでは、個人データを提供したことに当たらない場合があるとされています。

AI APIについて実際にどの法的整理になるかは、契約内容・利用方法・事業者によるデータ処理・国外移転などによって変わります。PIIマスキングは重要な技術的対策ですが、それだけで個人情報保護法やGDPRなどへの適合が自動的に保証されるわけではありません。センシティブな業務では、最新の契約・DPA・利用規約・自社のプライバシーポリシー・適用法令を個別に確認する必要があります。

マスキングと匿名化は同じではない

次のような置換を考えます。

氏名の置換
山田太郎 → <PERSON_1>

アプリケーション側に<PERSON_1> = 山田太郎という対応表を保存していれば、元の情報へ戻せます。これは一般に「元の人物との対応を切断した完全な匿名化」とは異なります。AI APIへ送る情報を減らすための仮名化・マスキングとして扱うほうが安全です。コード上でもanonymize()という名前ではなく、maskPii()のような名前にしておくと、後から「この処理を通せば法的に匿名情報になる」と誤解されにくくなります。

正規表現だけで全PIIを検出することはできない

メールアドレスや電話番号など、形式が決まっている情報は正規表現で検出できます。一方、氏名や住所は難しくなります。たとえば「佐藤」が人名なのか会社名の一部なのかを正規表現だけで判断することは困難です。「東京都港区」だけなら個人宅とは限りません。さらに「田中さんの自宅は駅の北側にある青いマンションの302号室」のように、完全な住所表記がなくても個人を特定できる情報があります。そのため実務では、構造化されたデータはフィールド単位で除去し、自由入力テキストについて正規表現やNERなどの検出処理を追加する二段階方式が扱いやすくなります。

TypeScriptでPIIマスキングを実装する

まず、検出したPIIの種類を定義します。

PiiType
type PiiType =
  | "EMAIL"
  | "PHONE"
  | "POSTAL_CODE"
  | "IP_ADDRESS"
  | "CARD_NUMBER"
  | "CUSTOM";

マスキング結果には、AIへ送る文章と元の値との対応表を持たせます。

PiiMapping / MaskResult
type PiiMapping = {
  placeholder: string;
  type: PiiType;
  original: string;
};

type MaskResult = {
  text: string;
  mappings: PiiMapping[];
};

ここで重要なのは、mappingsをAI APIへ送らないことです。対応表は自社サーバー内だけで管理します。

同じ値には同じプレースホルダーを割り当てる

同じメールアドレスが文章内に3回登場した場合、<EMAIL_1> <EMAIL_2> <EMAIL_3>と別々に置換すると、AIから見ると別の人物のメールアドレスのように見えます。同じ値には同じプレースホルダーを使います。

PlaceholderStore
class PlaceholderStore {
  private readonly byValue = new Map<string, string>();
  private readonly counters = new Map<PiiType, number>();
  readonly mappings: PiiMapping[] = [];

  getOrCreate(type: PiiType, original: string): string {
    const key = `${type}:${original}`;
    const existing = this.byValue.get(key);
    if (existing) {
      return existing;
    }

    const next = (this.counters.get(type) ?? 0) + 1;
    this.counters.set(type, next);

    const placeholder = `<${type}_${next}>`;
    this.byValue.set(key, placeholder);
    this.mappings.push({ placeholder, type, original });

    return placeholder;
  }
}

これにより、taro@example.comが複数回出てきても、<EMAIL_1>へ統一されます。

メールアドレスをマスキングする

メールアドレスは比較的正規表現で検出しやすい情報です。

maskEmail
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu;

function maskEmail(text: string, store: PlaceholderStore): string {
  return text.replace(EMAIL_PATTERN, (value) =>
    store.getOrCreate("EMAIL", value),
  );
}

使用例です。

使用例
const store = new PlaceholderStore();

const masked = maskEmail(
  `担当者は
taro.yamada@example.com
まで連絡してください。

返信先も
taro.yamada@example.com
です。`,
  store,
);

console.log(masked);

結果は次のようになります。

実行結果
担当者は
<EMAIL_1>
まで連絡してください。

返信先も
<EMAIL_1>
です。

同じ人物であるという意味を維持したまま、生のメールアドレスをAIへ送らずに済みます。

電話番号をマスキングする

日本の電話番号は表記方法が多いため、完全な正規表現を作るのは簡単ではありません。実務では対象サービスで入力可能な形式を決め、その形式に合わせて検出します。

maskPhone
const PHONE_PATTERN =
  /(?<!\d)(?:\+81[-\s]?\d{1,4}[-\s]?\d{1,4}[-\s]?\d{3,4}|0\d{1,4}[-\s]?\d{1,4}[-\s]?\d{3,4})(?!\d)/g;

function maskPhone(text: string, store: PlaceholderStore): string {
  return text.replace(PHONE_PATTERN, (value) =>
    store.getOrCreate("PHONE", value),
  );
}

たとえば「連絡先は090-1234-5678です。」なら「連絡先は<PHONE_1>です。」へ変換できます。ただし、電話番号に似た注文番号や管理番号を誤検出する可能性があります。正規表現によるPII検出では、検出漏れだけでなく誤検出も評価する必要があります。

郵便番号だけを消しても住所は残る

郵便番号も形式だけなら検出できます。

maskPostalCode
const POSTAL_CODE_PATTERN = /〒?\s?\d{3}-\d{4}/g;

function maskPostalCode(text: string, store: PlaceholderStore): string {
  return text.replace(POSTAL_CODE_PATTERN, (value) =>
    store.getOrCreate("POSTAL_CODE", value),
  );
}

しかし、「〒123-4567 東京都○○区○○1-2-3」から郵便番号だけ削除しても、住所本体が残ります(「<POSTAL_CODE_1> 東京都○○区○○1-2-3」)。したがって、郵便番号マスキングを「住所マスキング」と考えてはいけません。住所をDBの独立フィールドとして保持している場合は、そのフィールド自体をプロンプトへ含めない方法を優先します。

IPアドレスも必要に応じてマスキングする

サーバーログをAIへ要約させる場合はIPアドレスが含まれることがあります。IPアドレスが個人情報等に該当するかは利用状況や他情報との組み合わせによって判断が変わりますが、障害解析に値そのものが不要ならマスキングしておく方法があります。

maskIpv4
const IPV4_PATTERN = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;

function isValidIpv4(value: string): boolean {
  const parts = value.split(".");
  return (
    parts.length === 4 &&
    parts.every((part) => {
      const number = Number(part);
      return Number.isInteger(number) && number >= 0 && number <= 255;
    })
  );
}

function maskIpv4(text: string, store: PlaceholderStore): string {
  return text.replace(IPV4_PATTERN, (value) => {
    if (!isValidIpv4(value)) {
      return value;
    }
    return store.getOrCreate("IP_ADDRESS", value);
  });
}

ログ解析が目的なら、「192.168.1.15から大量アクセス」を「<IP_ADDRESS_1>から大量アクセス」へ変換しても、多くの場合は解析に必要な意味を保てます。

クレジットカードらしい番号はLuhnチェックする

数字が13〜19桁並んでいるだけでクレジットカード番号と判断すると、注文番号や伝票番号を大量に誤検出します。候補を正規表現で探した後、Luhnアルゴリズムで検証します。

maskCardNumber
const CARD_CANDIDATE_PATTERN = /(?<!\d)(?:\d[ -]?){13,19}(?!\d)/g;

function passesLuhn(value: string): boolean {
  const digits = value.replace(/\D/g, "");
  if (digits.length < 13 || digits.length > 19) {
    return false;
  }

  let sum = 0;
  let doubleDigit = false;

  for (let index = digits.length - 1; index >= 0; index -= 1) {
    let digit = Number(digits[index]);
    if (doubleDigit) {
      digit *= 2;
      if (digit > 9) {
        digit -= 9;
      }
    }
    sum += digit;
    doubleDigit = !doubleDigit;
  }

  return sum % 10 === 0;
}

function maskCardNumber(text: string, store: PlaceholderStore): string {
  return text.replace(CARD_CANDIDATE_PATTERN, (value) => {
    if (!passesLuhn(value)) {
      return value;
    }
    return store.getOrCreate("CARD_NUMBER", value);
  });
}

カード情報のような高リスクデータは、「マスキングしてAIへ送ればよい」と考えるのではなく、最初からAI処理経路へ入れない設計を優先します。

すべてをまとめたmaskPii関数

これまでのルールを一つにまとめます。

maskPii
export function maskPii(input: string): MaskResult {
  const store = new PlaceholderStore();

  let text = input;
  text = maskEmail(text, store);
  text = maskPhone(text, store);
  text = maskPostalCode(text, store);
  text = maskIpv4(text, store);
  text = maskCardNumber(text, store);

  return { text, mappings: store.mappings };
}

使用します。

使用例
const result = maskPii(`
山田太郎です。

メール:
taro@example.com

電話:
090-1234-5678

IP:
203.0.113.10

商品の返品方法を教えてください。
`);

console.log(result.text);

結果は次のようになります。

実行結果
山田太郎です。

メール:
<EMAIL_1>

電話:
<PHONE_1>

IP:
<IP_ADDRESS_1>

商品の返品方法を教えてください。

ここで「山田太郎」は残っています。正規表現だけでは人名を安全に判定できないからです。この点を解決するには、データ構造を利用します。

氏名はDBのフィールド段階で除去する

カスタマーサポートシステムでは、氏名が別カラムになっていることが多いはずです。

CustomerInquiry
type CustomerInquiry = {
  customerName: string;
  email: string;
  phone: string;
  message: string;
};

この場合、わざわざAIへ送った後で氏名を検出する必要はありません。プロンプト作成時点で置換します。

createSafeInquiryPrompt
function createSafeInquiryPrompt(inquiry: CustomerInquiry): string {
  const maskedMessage = maskPii(inquiry.message).text;

  return `
顧客:
<PERSON_1>

メール:
<EMAIL_1>

電話:
<PHONE_1>

問い合わせ:
${maskedMessage}
`.trim();
}

さらにメールや電話番号が回答生成に不要なら、項目そのものを削除できます。

createMinimalPrompt
function createMinimalPrompt(inquiry: CustomerInquiry): string {
  return `
以下の問い合わせを100文字以内で要約してください。

問い合わせ:
${maskPii(inquiry.message).text}
`.trim();
}

この方法が最も安全です。

自由入力の人名や住所はNERを検討する

問い合わせ本文・議事録・メール本文など、自由入力には人名や住所が直接書かれる可能性があります。正規表現では十分に検出できないため、Named Entity Recognitionなどの仕組みを追加できます。処理のイメージは次のようになります。

検出パイプライン
自由入力
  ↓
正規表現(メール・電話番号などを検出)
  ↓
NER(PERSON・LOCATIONなどを検出)
  ↓
プレースホルダーへ変換
  ↓
LLM API

ただし、PII検出のために外部AIサービスへ原文を送ると、そのPII検出サービス自体へ個人情報を送ることになります。個人情報を外部へ出さないことが目的なら、ローカルで実行できるNERモデルや、自社環境内の検出サービスを利用する必要があります。

Custom Termsで既知の個人情報を確実に消す

ユーザー情報が既にDBにあるなら、その値を使って本文から削除する方法もあります。

maskKnownValues
type KnownPii = {
  type: PiiType;
  value: string;
};

function maskKnownValues(
  text: string,
  values: readonly KnownPii[],
  store: PlaceholderStore,
): string {
  let result = text;

  for (const item of values) {
    if (!item.value.trim()) {
      continue;
    }

    const placeholder = store.getOrCreate(item.type, item.value);
    result = result.replaceAll(item.value, placeholder);
  }

  return result;
}

たとえばログイン中のユーザーについて、次のような配列を用意できます。

knownValues の例
const knownValues: KnownPii[] = [
  { type: "CUSTOM", value: customer.name },
  { type: "EMAIL", value: customer.email },
  { type: "PHONE", value: customer.phone },
];

本文にユーザー自身の名前が含まれていても、AIへ送る前に確実に置換できます。

名前専用のプレースホルダーを用意する

先ほどの例ではCUSTOMにしましたが、人名を明示的に管理するなら型を追加します。

PiiType(拡張版)
type PiiType =
  | "PERSON"
  | "EMAIL"
  | "PHONE"
  | "ADDRESS"
  | "POSTAL_CODE"
  | "IP_ADDRESS"
  | "CARD_NUMBER"
  | "CUSTOM";

DBから分かっている氏名や住所なら、<PERSON_1><ADDRESS_1>へ置換できます。LLMは「<PERSON_1>から<PERSON_2>へ連絡してください」という関係を維持したまま文章を処理できます。

プレースホルダーを元に戻す必要があるか確認する

AIの回答が「<PERSON_1><EMAIL_1>から連絡してください。」となった場合、アプリケーション側の対応表を使って元に戻すことはできます。

restorePii
function restorePii(
  text: string,
  mappings: readonly PiiMapping[],
): string {
  let restored = text;

  for (const mapping of mappings) {
    restored = restored.replaceAll(mapping.placeholder, mapping.original);
  }

  return restored;
}

ただし、必ず元に戻す必要はありません。問い合わせ内容のカテゴリ分類なら{"category": "delivery"}しか使わないため、復元処理そのものが不要です。要約でも管理画面へ匿名要約だけ表示するなら、プレースホルダーのままで問題ありません。必要な場合だけ復元するほうが安全です。

対応表をログへ出してはいけない

次のコードは、マスキング自体が成功していても意味がありません。

NG: 対応表をそのままログ出力
const masked = maskPii(message);
console.log(masked);

masked.mappingsには元のPIIが含まれています(例:{"placeholder": "<EMAIL_1>", "type": "EMAIL", "original": "taro@example.com"})。このオブジェクト全体をDatadog・Sentry・Cloud Loggingなどへ送れば、AI APIから削除した個人情報を別サービスへ保存することになります。ログにはPIIを除いた情報だけを残します。

OK: PIIを含まないログ
console.info({
  event: "ai_request_created",
  maskedTextLength: masked.text.length,
  maskedItemCount: masked.mappings.length,
  maskedTypes: [...new Set(masked.mappings.map((item) => item.type))],
});

元の値は出力しません。

APIリクエストを丸ごとログへ残さない

AI開発中には、デバッグ目的で次のようなコードを書きたくなります。

NG: リクエスト全文ログ
console.log({ requestBody });

本番では危険です。AI API側のデータ保持設定を厳格にしても、自社ログへプロンプト全文を永久保存していたら意味がありません。ログには、traceId・provider・model・inputTokens・outputTokens・latency・status・PII検出数など、障害解析に必要なメタデータだけを保存します。API呼び出しの記録テーブルを設計する場合も、プロンプト全文を保存する必要はありません。

エラーログにも個人情報が残る

正常時のログを削除しても、例外オブジェクトへリクエスト本文を含めるライブラリがあります。次のようにエラー全体をシリアライズする実装には注意します。

NG: エラー全体をシリアライズ
console.error(JSON.stringify(error));

エラーオブジェクトにHTTPリクエストBodyが含まれている場合、PIIも記録されます。ログへ出す項目を明示します。

OK: 必要な項目だけログ出力
function logAiError(error: unknown): void {
  if (error instanceof Error) {
    console.error({ name: error.name, message: error.message });
    return;
  }

  console.error({ message: "Unknown AI API error" });
}

プロバイダーのRequest IDが取得できる場合は、それを記録して調査に使います。

RAGでは検索チャンクにもPIIが含まれる

ユーザーの質問だけをマスキングしても、RAGから取得したチャンクに個人情報があれば、最終的にAI APIへ送信されます。たとえば社内の問い合わせ履歴をVector Databaseへ登録している場合、「山田太郎 / 090-1234-5678 / 契約プランA / 解約希望」という原文がRAGで取得され、そのままLLMへ渡される可能性があります。RAGでは、取り込み前・検索後・LLM送信前のどこでPIIを除去するか設計します。検索に氏名が不要なら、Embeddingを作る前にマスキングしておく方法が安全です。元データが必要な業務では、原本はアクセス制御されたDBへ置き、Vector DatabaseにはPIIを除いた検索用テキストだけを保存する構成も考えられます。

Prompt Cachingにもマスキング後の文章を入れる

Prompt Cachingを利用すると、共通プロンプトの一部がキャッシュ対象になります。個人情報をキャッシュ対象へ入れないよう、マスキングはキャッシュ処理より前に行います(原文 → PIIマスキング → プロンプト構築 → Prompt Cache → モデル、という順番です)。OpenAIではPrompt CachingがApplication StateとしてGPUローカル領域に一時データを保持する場合があり、GeminiのExplicit Context Cachingも指定したTTLまでキャッシュされたコンテンツを保存します。巨大な顧客データをそのままキャッシュしてからマスキングする、という順番にしてはいけません。

File APIにもPIIが残る

PDFをアップロードして質問する機能では、本文だけでなくファイル自体が保存対象になります。OpenAI Files APIではファイルを手動削除でき、自動削除期限も設定できます。ファイル関連APIにはApplication Stateとして削除まで保存されるものがあります。Gemini File APIでも、ファイルはユーザーが削除するか期限切れになるまで保存されます。GeminiのZDR説明でも、絶対的なZero Data Footprintを目指す場合はファイル管理を別途行う必要があると説明されています。PDF本文をマスキングしていても、「山田太郎_診断書.pdf」というファイル名自体に個人情報が含まれていることもあります。ファイル本文・ファイル名・メタデータのすべてを確認します。

画像にも個人情報が含まれる

PIIマスキングというとテキストだけを考えがちですが、画像にも個人情報があります。免許証・社員証・名刺・伝票・画面キャプチャ・顔写真などをVisionモデルへ送れば、画像自体が個人情報を含みます。テキスト用のmaskPii()では画像内の文字や顔を削除できません。必要に応じて、AI APIへ送る前に画像上で顔・氏名・住所・QRコード・バーコード・識別番号などをぼかす処理が必要です。EXIFなどの画像メタデータも確認します。

Tool Calling経由の送信先も確認する

AIモデル自体のデータ管理だけを確認しても、Function CallingやMCPを通じて第三者サービスへデータが送信される場合があります。OpenAIも、Responses APIからRemote MCP Serverなど第三者サービスへ送信したデータについては、その第三者のデータ保持ポリシーが適用されると説明しています。Claudeでも第三者インテグレーションはAnthropicのZDR対象外とされています。たとえば「ユーザー → 自社サーバー → OpenAI → MCP → 外部CRM」という構成なら、OpenAIだけでなく外部CRMとMCPサーバーのログや保存期間も確認する必要があります。

JSON Schemaへ個人情報を埋め込まない

Structured Outputsでは、スキーマそのものへ具体的な個人情報を入れないようにします。悪い例は次のような構成です。

NG: enumに個人名を埋め込む
const schema = {
  type: "object",
  properties: {
    customer: {
      type: "string",
      enum: ["山田太郎", "佐藤花子"],
    },
  },
};

個人名をenumへ埋め込む必要はありません。

OK: IDで一般化する
const schema = {
  type: "object",
  properties: {
    customerId: {
      type: "string",
    },
  },
};

のように、スキーマは一般化します。AnthropicもPHIを扱う構成では、Structured OutputsやStrict ToolsのJSON Schemaがメッセージ本文とは異なる形でキャッシュされるため、スキーマのプロパティ名・enumconstなどへ患者固有情報を含めないよう明示しています。この考え方は医療用途に限らず、PIIを扱うStructured Outputsでも有効です。

マスキング後にAI APIへ送る

OpenAI Responses APIなら次のように組み込めます。

summarizeInquiry
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

async function summarizeInquiry(originalText: string): Promise<string> {
  const masked = maskPii(originalText);

  const response = await openai.responses.create({
    model: process.env.OPENAI_MODEL!,
    store: false,
    instructions: `
あなたは問い合わせを要約するアシスタントです。
<EMAIL_1>や<PHONE_1>などのプレースホルダーは変更せず、そのまま出力してください。
`.trim(),
    input: masked.text,
  });

  return response.output_text;
}

モデルには、プレースホルダーを勝手に補完しないよう指示します。<PERSON_1>から実在人物の名前を推測する必要はありません。

AIの回答もPIIチェックする

入力をマスキングしていても、回答側に個人情報が現れない保証はありません。RAG・Tool Calling・会話履歴など、別の入力経路からPIIを取得している可能性があります。ユーザーへ返す前に、必要に応じて同じPII検出処理を適用します。

出力側のPIIチェック
const output = response.output_text;
const checkedOutput = maskPii(output);

if (checkedOutput.mappings.length > 0) {
  console.warn({
    event: "pii_detected_in_ai_output",
    count: checkedOutput.mappings.length,
  });
}

そのままマスキングして表示するか、回答を拒否するかはサービスの用途によって決めます。

APIキーやパスワードも一緒に検出する

PIIとは別ですが、自由入力には秘密情報が混ざる可能性があります。たとえば開発者向けAIサービスでは、APIキーやAuthorizationヘッダー、DB接続文字列などがユーザーの質問へ含まれることがあります。これらは「個人情報ではないから送ってよい」とはなりません。PII検出とは別にSecret Detectionを行い、<API_KEY_1><ACCESS_TOKEN_1><DATABASE_URL_1>などへ置換する処理を追加します。GitHubのコードレビュー・エラーログ解析・設定ファイルの解説などでは、PIIよりシークレット漏えいのほうが発生しやすい場合もあります。

マスキング処理自体をテストする

PIIマスキングは、数件の文章で確認して終わりにしてはいけません。実際の入力に近いテストデータを作成します。

maskPii.test.ts
import { describe, expect, it } from "vitest";

describe("maskPii", () => {
  it("メールアドレスをマスクする", () => {
    const result = maskPii("連絡先はtaro@example.comです");
    expect(result.text).toBe("連絡先は<EMAIL_1>です");
  });

  it("同じメールは同じIDになる", () => {
    const result = maskPii(
      ["taro@example.com", "taro@example.com"].join(" "),
    );
    expect(result.text).toBe("<EMAIL_1> <EMAIL_1>");
  });

  it("IPv4をマスクする", () => {
    const result = maskPii("203.0.113.10からアクセス");
    expect(result.text).toContain("<IP_ADDRESS_1>");
  });
});

検出できたケースだけでなく、誤検出しないケースも追加します。

誤検出防止のテスト
it("通常の注文番号を電話番号扱いしない", () => {
  const result = maskPii("注文番号は123456です");
  expect(result.text).toBe("注文番号は123456です");
});

PrecisionとRecallの両方を見る

PII検出にも検索システムと同じようにPrecisionとRecallの考え方があります。本当はPIIではない文字列まで大量に削除するとPrecisionが低くなります。逆に、本名や住所を見逃してAI APIへ送ってしまえばRecallが低い状態です。個人情報保護では見逃しのリスクが大きいためRecallを高めたいところですが、何でもPIIとして消すと問い合わせ内容を理解できなくなります。実データを匿名化したテストセットで、検出できたPII・見逃したPII・誤検出した文字列を記録し、ルールを調整します。

検出漏れを前提に設計する

PIIマスキングを100%正確な安全装置として扱わないことも重要です。正規表現やNERには検出漏れがあります。そのため、「PIIマスキングが成功する→だからどんなデータでも送ってよい」という設計にはしません。送信データを最小化する・契約や保存条件を確認する・必要なPIIをマスクする・ログもマスクする・アクセス権を制限する・保存期間を短くする、という複数の対策を重ねます。マスキングは最後の防御ではなく、複数ある防御の一つです。

PIIマスキングを行う場所

最も分かりやすいのは、自社バックエンドでAI APIを呼び出す直前です(ブラウザ → 自社API → 入力検証 → PIIマスキング → AI用プロンプト生成 → AI API、という流れです)。APIキーをブラウザへ置き、ブラウザからAI事業者へ直接リクエストすると、サーバー側で統一的にPIIチェックすることが難しくなります。AI APIキーを安全に管理する意味でも、バックエンドを経由する構成が扱いやすくなります。さらに重要なシステムでは、サービスごとにPII処理を書くのではなく、共通のAI Gatewayへ集約します。

AI Gateway クライアントの例
const safeAiClient = createSafeAiClient({
  piiMasking: true,
  secretMasking: true,
  logPrompts: false,
});

AI Gatewayでマスキングを強制する

複数のサービスがOpenAI・Claude・Geminiを直接呼び出すと、ある機能だけPIIマスキングを忘れる可能性があります。AI Gatewayを作り、「各アプリ → AI Gateway → PIIチェック → ログ制御 → プロバイダー選択 → OpenAI / Claude / Gemini」という構成にすると対策を統一できます。複数のAIプロバイダーを切り替える実装はVercel AI SDK完全ガイドも参考になります。

SafeAiRequest
type SafeAiRequest = {
  operation: string;
  userId: string;
  prompt: string;
  allowPii: boolean;
};

通常機能ではallowPii: falseを既定値にします。

既定でマスキングを強制
if (!request.allowPii) {
  request.prompt = maskPii(request.prompt).text;
}

個人情報を送信する必要がある特別な機能だけ、明示的な審査を通して許可する仕組みにできます。

PIIが必要な処理まで全部マスクしない

すべてのAI処理で個人情報を削除できるわけではありません。たとえば「山田太郎様宛ての案内メールを作ってください」という処理では、最終的に顧客名が必要です。それでも、モデルに本名を渡す必要があるとは限りません。AIには「<PERSON_1>様宛ての案内メールを作ってください」と送ります。AIから「<PERSON_1>様 いつもご利用いただき…」が返った後、自社サーバーで<PERSON_1>を山田太郎へ復元します。文章生成に必要なのは「ここに宛名がある」という意味であり、本名そのものではありません。

マスキングすると品質が落ちるケースもある

AIが個人情報そのものについて判断する処理では、置換によって必要な情報を失うことがあります。たとえば「この住所から都道府県を抽出してください」という処理で住所全体を<ADDRESS_1>へ変換したら、モデルは都道府県を判定できません。このような処理は、LLMへ送る前に通常のプログラムで処理できないか検討します。住所から都道府県を抽出するだけなら、AI APIを使わずローカルコードや住所データベースで処理できる可能性があります。AIが必要な場合は、目的に必要な部分だけ残します。「東京都<ADDRESS_REDACTED>」のように、都道府県は保持し、それ以降を削除する方法もあります。

「AIに必要な粒度」まで情報を粗くする

マスキングは完全削除だけではありません。年齢が必要でも、生年月日そのものが不要なら「1987年4月12日」を「30代」へ変換できます。住所も「東京都港区○○1-2-3」ではなく「東京都」だけで十分な処理があります。正確な給与額が不要なら「年収5,823,400円」を「年収500万円台」にできます。AIの目的を満たせる最も粗い情報へ変換すると、再識別リスクを下げられます。

本番導入前に確認したいこと

AI APIへ個人情報を送るシステムでは、最初に利用目的を明確にします。その目的に個人情報そのものが必要なのか、プレースホルダーで代替できるのかを確認します。次に、使用するAIプロバイダー・モデル・API・Files・Caching・Grounding・Toolsなどのデータ保持条件を確認します。その後、入力前のPIIマスキング・出力側のPII検査・自社ログのマスキング・データ削除手順を用意します。さらに、海外リージョンで処理される可能性・DPA・ZDR・組織内のアクセス制御・自社プライバシーポリシーとの整合性も確認します。日本の個人情報保護委員会も、「個人情報」は氏名だけでなく、他の情報との照合によって個人を識別できる情報を含むとしているため、単純な名前削除だけで十分とは限りません。

よくある質問

QOpenAI APIへ個人情報を送っても学習されませんか?

A2026年8月時点では、OpenAI APIの主要エンドポイントへ送信したデータは標準でモデル学習には使用されません。ただし、標準ではAbuse Monitoring Logsが最大30日保存される場合があります。また、Responses・Files・Vector StoresなどのApplication Stateは機能ごとに異なる保存条件があります。学習利用と保存期間を分けて確認する必要があります。

QGeminiの無料APIへ個人情報を送ってもよいですか?

AGoogleの現行規約では、Gemini APIのUnpaid Servicesについて、センシティブ情報・機密情報・個人情報を送信しないよう明記されています。Unpaid Servicesでは入力と出力が製品改善などに利用され、人間のレビュアーが確認する場合もあります。個人情報を扱う用途では特に注意が必要です。

QGeminiの有料APIなら保存されませんか?

APaid ServicesではプロンプトやレスポンスはGoogle製品の改善には利用されませんが、安全性・不正利用検知などのため限定期間ログへ保存される場合があります。さらにSearch Grounding・Maps Grounding・Interactionsの状態保存・File API・Context Cachingなどは別途保存条件があります。「有料=一切保存されない」ではありません。

QClaude APIならデータは残りませんか?

AClaude APIでは機能やモデルによって保持条件が異なります。Anthropicは、技術上不要な場合には会話内容を標準で保持しない方針を示していますが、一部モデルには30日保持要件があります。ZDRもすべてのClaude製品・機能へ適用されるわけではありません。契約と実際に利用する機能を確認する必要があります。

Q氏名を削除すれば個人情報ではなくなりますか?

A必ずしもそうとは限りません。住所・メールアドレス・電話番号・顧客番号・勤務先・属性の組み合わせなどから特定の個人を識別できる場合があります。個人情報保護委員会も、他の情報と容易に照合して個人を識別できる情報を個人情報の範囲に含めています。

QPIIマスキングすれば法令対応は完了しますか?

A完了しません。PIIマスキングはデータ漏えいリスクを下げる技術的対策の一つです。利用目的・契約・第三者提供や委託の整理・安全管理措置・国外処理・保存期間・本人への説明など、適用される法令や業務に応じた確認が別途必要です。個人情報保護委員会も、クラウドサービスにおける個人データの扱いは、サービス事業者が実際に個人データを取り扱うかどうかなどによって判断が変わるとしています。

まとめ

AI APIへ個人情報を送る場合、最初に行うべきことは正規表現を書くことではありません。まず、その個人情報がAI処理に本当に必要なのかを確認します。問い合わせ分類なら氏名やメールアドレスを送らず、文章生成で人物の区別だけ必要なら<PERSON_1>のようなプレースホルダーへ置換します。

メールアドレス・電話番号・IPアドレスなど形式が決まった情報はTypeScriptの正規表現で検出できます。一方、人名や住所を正規表現だけで完全に検出することは困難です。DBに氏名や住所が独立して保存されているなら、プロンプトを作る段階で除外する方法を優先します。自由入力ではNERや既知データとの照合を組み合わせます。さらに、入力プロンプトだけでなく、RAGチャンク・ファイル名・画像・Prompt Cache・Tool Calling・MCP・自社ログ・エラートラッキングにもPIIが流れないか確認します。

OpenAI、Claude、Geminiはいずれもデータ管理機能を提供していますが、保存条件はAPI・モデル・機能・契約によって異なります。特にGeminiではUnpaid ServicesとPaid Servicesのデータ利用条件が異なり、Unpaid Servicesには個人情報を送信しないようGoogle自身が案内しています。TypeScriptからのAPI呼び出し自体は【TypeScript】OpenAI API入門【TypeScript】Claude API入門もあわせて参考にしてください。

安全なデータフロー
必要なデータだけ取得する
  ↓
不要なフィールドを削除する
  ↓
残ったPIIをマスキングする
  ↓
マスキング後だけログへ渡す
  ↓
AI APIへ送信する
  ↓
回答もPIIチェックする
  ↓
必要な場合だけ自社サーバーで復元する

PIIマスキングを「個人情報を何でもAIへ送れるようにする仕組み」と考えるのではなく、AIへ渡す情報を最小限に抑えるための最後の一段として組み込むことが重要です。