Web Push API を使うと、ブラウザを開いていないユーザーにも通知を届けられます。仕事で実装する機会があり、調べた内容を忘備録としてまとめておきます。Service Worker・VAPID・Push サービスの 3 つが連携する仕組みを理解すれば、あとはライブラリが面倒な暗号化処理を代わりにやってくれます。サーバーサイドの実装は PHP(minishlink/web-push)で試す手順を紹介します。

Webプッシュのざっくり概要

Web プッシュは、ブラウザと OS の通知機能を使って「ブラウザを開いていなくても」通知を届ける仕組みです。

  • ユーザーがブラウザで「通知を許可」する
  • ブラウザが Push サービス(FCM / APNs など)とやり取りして 購読情報(Subscription) を発行
  • サーバー側は、その購読情報(endpoint / p256dh / auth)を DB に保存
  • 通知を送りたいときは、サーバーアプリケーションで Push サービスへリクエストを投げる
  • Push サービス → OS の通知チャネル → Service Worker → 通知表示

という流れになります。

Image description

Service Worker が Web Push API の橋渡しを担います。Service Worker は JS で実装されたプログラムで、ブラウザにインストールされ、メインスレッドとは別スレッドで動作します。また、Service Worker を登録するページおよび Service Worker スクリプトは HTTPS で配信されている必要がある点に注意してください(ただし localhost は例外的に http でも動作します)。

もう少し詳しく

もう少し詳しく説明すると、Web プッシュでは購読された時点でブラウザ側で一意のエンドポイントが発行されます。
このエンドポイントはブラウザベンダーごとに異なり、Google Chrome なら FCM(https://fcm.googleapis.com/...)、Safari / iOS なら APNs 経由のエンドポイントという形になります。

アプリ側は、このエンドポイント URL に対して暗号化済みのメッセージを HTTPS で POST すると、あとはベンダー側の Push サービスが OS の通知チャネルにプッシュしてくれます。

Web プッシュ自体はオープンな仕様で、FCM や APNs に対して「事前に個別の契約を結ぶ」といったことは不要です。

ただし、エンドポイントにメッセージを送る際は

  • ペイロードの暗号化(p256dh / auth を使う)
  • VAPID による送信元サーバーの署名(公開鍵 / 秘密鍵)

といった処理が必要で、ここが実装として少しややこしいところです。

① ユーザー毎の購読・エンドポイント管理

ユーザーが通知を許可して購読すると、ブラウザから Subscription 情報が取れます。


【ユーザーのブラウザ】
|
| 通知を許可・購読 → 購読情報(Subscription)が発行される
v
【ブラウザ】
|
| エンドポイント情報をサーバへ送信(非同期)
v
【Webサーバー】
|
| エンドポイントなどをDBに保存して管理
v
【DB】

JavaScript 側で pushManager.subscribe() を呼ぶと、だいたい次のような JSON が取れます。

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/xxxxxxxxxxxxxxxx",
  "expirationTime": null,
  "keys": {
    "p256dh": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "auth":   "YYYYYYYYYYYYYYYYYYYY"
  }
}
  • endpoint

    • そのブラウザ(+Push サービス)の組み合わせに通知を送るための URL(固有の URI)
    • 通常は一定期間同じものが使われますが、ブラウザの内部状態や Push サービス側の都合で変わる可能性はあります。 → 再購読時は「新しい Subscription で上書き保存」する前提にしておくのが安全
  • p256dh / auth

    • p256dh: ブラウザ側の公開鍵(ECDH)
    • auth: 認証用のシークレット
    • これらは メッセージ暗号化のために使う値 で、VAPID の鍵ペアとは別物です。

この Subscription 一式(endpoint / p256dh / auth)があれば、そのブラウザに対して Push 通知が送れてしまうので、 DB 上では機密情報として扱う のが良いと思います(ログにそのまま出さない、第三者に共有しない等)。

② Webプッシュ通知のフロー

【Webサーバー】
     |
     | DBから対象ユーザーの購読情報(Subscription)取得
     |
     | Pushサービスへ通知送信(HTTPS + VAPID)
     |      ※VAPID 秘密鍵で JWT を ES256 署名
     v
【Pushサービス(Google / Apple など)】
     |
     | 端末へプッシュ(OSの通知チャネル)
     v
【ユーザー端末(Service Worker)】
     |
     | 通知を表示
     |      ※通知クリック時のイベントハンドラを仕込める

Service Worker 側では self.registration.showNotification() を呼び出して通知を表示します。 notificationclick イベントを拾えば、クリック時に

  • 特定の URL を開く
  • 既存タブをフォーカスして postMessage する

といった制御も可能です。

対応ブラウザ(ざっくり)

  • Android

    • Chrome 等で Web プッシュが普通に使えます(通常のブラウザタブでも OK)。
  • iPhone / iPad

    • iOS / iPadOS 16.4 以降
    • Safari から ホーム画面に追加した Web アプリ(PWA、スタンドアロン表示) で Web プッシュ対応
    • 通常の Safari タブだけでは Web プッシュは使えない点に注意。
    • Manifest・Service Worker を備えた PWA で、ホーム画面アイコンから起動したときにだけ購読が可能、というイメージです。

VAPID 鍵(公開鍵・秘密鍵)を用意する

Web プッシュでは、アプリケーションサーバーを識別するために VAPID(Voluntary Application Server Identification) という仕組みを使います。

VAPID 用の鍵ペアは web-push CLI で生成できます。

npx web-push generate-vapid-keys

出力例:

=======================================
Public Key:
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Private Key:
YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY=
=======================================
  • 公開鍵

    • ブラウザ側(クライアント JS)で applicationServerKey として使用
  • 秘密鍵

    • サーバー側で JWT を ES256 署名するのに使用
    • 第三者に漏洩しないよう、環境変数や設定ファイル等で厳重に管理する

ここで生成する VAPID 鍵と、Subscription の p256dh / auth は役割が異なります。

  • p256dh / authメッセージ暗号化のためのブラウザ側鍵情報
  • VAPID 鍵 → 送信元サーバーを識別するための署名用鍵

購読の動作確認(ローカル)

まずはローカルでサクッと試します。 単純に python3 -m http.serverindex.htmlsw.js を配信しました。

sw.js:

self.addEventListener("push", event => {
	const data = event.data ? event.data.text() : "No payload";
	self.registration.showNotification("通知", { body: data });
});

index.html:

<!DOCTYPE html>
<html lang="ja">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebPush Test</title>
<style>
	body {
		font-family: sans-serif;
		padding: 20px;
		line-height: 1.6;
	}
	button {
		width: 100%;
		padding: 14px;
		font-size: 18px;
		border-radius: 8px;
		margin-bottom: 12px;
	}
	#output {
		margin-top: 20px;
		padding: 12px;
		background: #f5f5f5;
		border-radius: 8px;
		word-break: break-all;
	}
</style>
<body>

<button id="btn">購読する</button>
<button id="btn-unsub">購読解除</button>
<div id="output"></div>

<script>
	const vapidPublicKey = "XXXXXXXXXXXXXXXXXXX";
	const out = document.getElementById("output");

	window.addEventListener("load", async () => {
		if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
			out.textContent = "このブラウザは Web Push に対応していません";
			return;
		}

		await navigator.serviceWorker.register("./sw.js");
		const reg = await navigator.serviceWorker.ready;
		const sub = await reg.pushManager.getSubscription();

		if (sub) {
			out.innerHTML = `<pre>${JSON.stringify(sub, null, 2)}</pre>`;
		} else {
			out.textContent = "まだ購読していません";
		}
	});

	document.getElementById("btn-unsub").addEventListener("click", async () => {
		const reg = await navigator.serviceWorker.ready;
		const sub = await reg.pushManager.getSubscription();

		if (!sub) {
			out.innerHTML = "まだ購読していません";
			return;
		}

		const ok = await sub.unsubscribe();
		out.innerHTML = ok ? "購読解除しました" : "購読解除に失敗しました";
	});

	document.getElementById("btn").addEventListener("click", async () => {
		if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
			out.textContent = "このブラウザは Web Push に対応していません";
			return;
		}

		const perm = await Notification.requestPermission();
		if (perm !== "granted") {
			out.textContent = "通知が許可されていません";
			return;
		}

		await navigator.serviceWorker.register("./sw.js");
		const reg = await navigator.serviceWorker.ready;

		let sub = await reg.pushManager.getSubscription();

		if (!sub) {
			sub = await reg.pushManager.subscribe({
				userVisibleOnly: true,
				applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
			});
		}

		out.innerHTML = `<pre>${JSON.stringify(sub, null, 2)}</pre>`;
	});

	function urlBase64ToUint8Array(base64String) {
		const padding = "=".repeat((4 - base64String.length % 4) % 4);
		const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
		const rawData = atob(base64);
		return Uint8Array.from([...rawData].map(c => c.charCodeAt(0)));
	}
</script>

</body>
</html>
python3 -m http.server 3000

http://localhost:3000/ にアクセスし、購読すると以下のような情報が得られる:

Subscription: {"endpoint":"https://fcm.googleapis.com/fcm/send/xxxxxxxxxxxxx","expirationTime":null,"keys":{"p256dh":"XXXXXXXXXXXXX","auth":"XXXXXXXXXXXXX"}}
  • endpoint は購読者のブラウザに通知するための固有な URI。 普通はそのまま使い続けられますが、仕様上は変わる可能性もあるので、 再購読時はその都度 DB を上書きする 前提で設計しておいた方が無難です。
  • エンドポイントが漏洩すると第三者から不要な Push を飛ばされる可能性があるため、ログや画面に安易に晒さない方がよいです。
  • keysp256dhauth トークンは、Web プッシュでペイロード付きメッセージを送る際の 暗号化に必要な値 です。VAPID の公開鍵・秘密鍵とは別系統の情報なので混同しないよう注意。

VAPID まわりや暗号化を一から実装するのは結構大変なので、普通は既存ライブラリのエコシステムに乗るのが現実的です。 ここでは PHP の minishlink/web-push を使っています。

どうしてもレガシーなシステムでライブラリが使えないという場合は、自前で実装する必要があります。私も Java で実装する必要があり、ChatGPT 5.1 Thinking のおかげでなんとかなりましたが、結構難解なプログラムでした。

PHPで Web プッシュする

composer require minishlink/web-push
<?php
// 実行方法: php send.php

require_once __DIR__ . '/vendor/autoload.php';

use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;

// --- あなたの VAPID 鍵 ---
$vapid = [
    'VAPID' => [
        'subject' => 'mailto:[email protected]',
        'publicKey' => 'XXXXXXXXXXXXXXXX',
        'privateKey' => 'XXXXXXXXXXXXXXXX',
    ],
];

// --- あなたの購読情報 ---
$subscription = Subscription::create([
    "endpoint" => "https://fcm.googleapis.com/fcm/send/XXXXXXXXXXXXXXXXXXX",
    "keys" => [
        "p256dh" => "XXXXXXXXXXXXXXXXXXX",
        "auth" => "XXXXXXXXXXXXXXXXXXX"
    ]
]);

$webPush = new WebPush($vapid);

$payload = json_encode([
    "title" => "Push通知テスト",
    "body"  => "PHPから送信しました"
]);

$res = $webPush->sendOneNotification($subscription, $payload);

echo "送信: " . date("H:i:s") . PHP_EOL;

minishlink/web-push 側で

  • メッセージ暗号化(p256dh / auth
  • VAPID 署名(公開鍵 / 秘密鍵)

をまとめて面倒見てくれるので、アプリ側は「購読情報」と「ペイロード」を渡すだけで Push を飛ばせます。

※ 実際の運用では、サンプルコードにベタ書きせず、VAPID 鍵や Subscription 情報は環境変数や DB で安全に管理した方が良いです。

HTTPS と localhost について

Web プッシュは セキュアコンテキスト(HTTPS) が前提で、原則として HTTP では購読できません。 ただし例外として、http://localhost はブラウザから「セキュア扱い」されるため、ローカル開発では HTTP のままでも Web プッシュを試すことができます。

LAN 内の別マシンやスマホで Web プッシュを試したい場合は、Cloudflare Tunnel でローカルサーバーを HTTPS 公開する といった方法を使うと、実運用に近い形で検証できて便利です。

関連アイテム

Web Push や Service Worker の学習に役立つ書籍・資料です。