Pages Functions での認証
Cloudflare Pages Functions を使った認証パターン
概要
Pages Functions は外部認証プロバイダー(Auth0 など)との連携やカスタムトークン検証を実装することで認証を処理できます。
ここで最も重要なのは どの種類のトークンを検証するのか という点です。それによって必要な検証方法が決まります。コードをコピーする前に、まず下の 2 つの仕事、2 つの道具 を読んでください。
2 つの仕事、2 つの道具
Worker / Pages Function での認証は、たいてい次の 2 つのまったく異なる仕事のどちらかです。アルゴリズムも検証の仕組みも違うので、混同しないでください。
| 仕事 | トークンの発行者 | アルゴリズム | 検証方法 |
|---|---|---|---|
| サードパーティ IdP のトークンを検証する(Auth0、Google など) | 自分が管理していない外部の ID プロバイダー | RS256(非対称鍵) | プロバイダーの公開鍵(JWKS)を取得し、Web Crypto でローカルに署名を検証 |
| 自前のセッション トークンを発行・検証する | 自分のバックエンド | HS256(対称鍵) | wrangler secret に保管した共有シークレットで署名・検証 |
両者が異なる理由はこうです。サードパーティ IdP の場合、署名鍵を持っているのはプロバイダーだけで、自分は決して所有しません。プロバイダーは対応する 公開 鍵セット(JWKS)を配布し、自分はそれに対して検証します。一方、自前のセッショントークンは両端を自分で管理しているので、ひとつの共有シークレット(HS256)のほうがシンプルかつ高速です。
このページの残りでは、まずサードパーティのケース(RS256 + JWKS)を詳しく扱い、続いて自前のケース(HS256)を手短に示します。
サードパーティ IdP のトークンを検証する(RS256 + JWKS)
Wrangler 設定
[vars]
AUTH0_DOMAIN = "your-tenant.us.auth0.com"
AUTH0_CLIENT_ID = "your-client-id"
AUTH0_AUDIENCE = "https://your-api.example.com"
🚨 シークレットと変数の違い
AUTH0_CLIENT_ID は公開情報なので [vars] に記述して問題ありません。しかし AUTH0_CLIENT_SECRET は wrangler secret put AUTH0_CLIENT_SECRET で設定する必要があります — wrangler.toml には絶対に書かないでください。
ローカルで検証する — リクエストごとに /userinfo を呼ばない
ありがちですが間違ったやり方は、リクエストごとに IdP の /userinfo エンドポイントへベアラートークンを付けて検証することです。これは各リクエストを Auth0 へのネットワークラウンドトリップ にしてしまい、レイテンシが増え、アプリの可用性が IdP に依存し、IdP のレート制限を消費します。
トークンは 署名済みの JWT です。Web Crypto API だけを使ってオフラインで検証できます — Node の jsonwebtoken も Node の crypto も、リクエストごとの外部通信も不要です。IdP の公開鍵を 一度だけ 取得してキャッシュし、署名はローカルで検証します。
JWT は base64url でエンコードされた 3 つの部分 header.payload.signature から成ります。検証とは次のことです:
- ヘッダーから
kid(鍵 ID)を読み取る。 - IdP の JWKS から一致する公開鍵を探す。
- その鍵で
header.payloadに対する署名を検証する。 iss・aud・expのクレームを検証する。
モジュールレベルのキャッシュ付き JWKS 取得
キャッシュをモジュールスコープで定義し、同じアイソレート内のリクエスト間で生存する ようにします。再取得が走るのは、1 時間の TTL が切れたとき、またはキャッシュに無い kid をトークンが参照したとき(鍵のローテーション)だけです。
// functions/auth/jwks.ts
interface JwksKey {
kty: string;
kid: string;
use: string;
alg: string;
n: string;
e: string;
}
interface JwksResponse {
keys: JwksKey[];
}
// Module-level cache: survives across requests in the same isolate.
let jwksCache: { keys: JwksKey[]; fetchedAt: number } | null = null;
const JWKS_CACHE_TTL = 3600_000; // 1 hour
export async function fetchJwks(
domain: string,
forceRefresh = false,
): Promise<JwksKey[]> {
const now = Date.now();
if (!forceRefresh && jwksCache && now - jwksCache.fetchedAt < JWKS_CACHE_TTL) {
return jwksCache.keys;
}
const url = `https://${domain}/.well-known/jwks.json`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch JWKS: ${res.status}`);
}
const data = (await res.json()) as JwksResponse;
jwksCache = { keys: data.keys, fetchedAt: now };
return data.keys;
}
Web Crypto による RS256 検証
base64url を手作業でデコードし(JWT は URL セーフな文字種を使い、パディングがありません)、JWK を RSASSA-PKCS1-v1_5 鍵としてインポートして検証します。kid がキャッシュに見つからない場合は、鍵ローテーションに対応するため JWKS を一度だけ再取得します。
// functions/auth/verify.ts
import { fetchJwks } from "./jwks";
export interface Auth0Claims {
sub: string;
email?: string;
name?: string;
exp: number;
iat: number;
iss: string;
aud: string | string[];
}
function base64urlDecode(str: string): Uint8Array {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const paddingNeeded = (4 - (base64.length % 4)) % 4;
const binary = atob(base64 + "=".repeat(paddingNeeded));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(new TextDecoder().decode(base64urlDecode(part)));
}
export async function verifyAuth0Token(
token: string,
domain: string,
audience: string,
): Promise<Auth0Claims> {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("Invalid token format");
const [headerB64, payloadB64, sigB64] = parts;
// 1. Read kid + alg from the header.
const header = decodeJwtPart(headerB64) as { alg: string; kid: string };
if (header.alg !== "RS256") throw new Error("Unsupported algorithm");
if (!header.kid) throw new Error("Missing kid in token header");
// 2. Find the matching key; refetch JWKS once on a kid miss (rotation).
let keys = await fetchJwks(domain);
let jwk = keys.find((k) => k.kid === header.kid);
if (!jwk) {
keys = await fetchJwks(domain, true);
jwk = keys.find((k) => k.kid === header.kid);
}
if (!jwk) throw new Error("No matching key found in JWKS");
// 3. Import the RSA public key.
const cryptoKey = await crypto.subtle.importKey(
"jwk",
{ kty: jwk.kty, n: jwk.n, e: jwk.e },
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"],
);
// 4. Verify the signature over `header.payload`.
const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const signature = base64urlDecode(sigB64);
const valid = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
cryptoKey,
signature,
signingInput,
);
if (!valid) throw new Error("Invalid signature");
// 5. Validate claims.
const payload = decodeJwtPart(payloadB64) as unknown as Auth0Claims;
const expectedIss = `https://${domain}/`;
if (payload.iss !== expectedIss) {
throw new Error(`Invalid issuer: expected ${expectedIss}`);
}
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!aud.includes(audience)) {
throw new Error("Token audience does not match");
}
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error("Token expired");
}
return payload;
}
認証ミドルウェア関数
ミドルウェアはトークンを ローカルで 検証するようになり、リクエストごとに IdP へ問い合わせることはありません。唯一のネットワーク通信は JWKS の取得で、これはアイソレートごとに最大でも 1 時間に 1 回だけ発生します。
// functions/auth/_middleware.ts
import { verifyAuth0Token } from "./verify";
interface Env {
AUTH0_DOMAIN: string;
AUTH0_CLIENT_ID: string;
AUTH0_AUDIENCE: string;
}
export const onRequest: PagesFunction<Env> = async (context) => {
const authHeader = context.request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return new Response("Unauthorized", { status: 401 });
}
const token = authHeader.slice(7);
try {
const claims = await verifyAuth0Token(
token,
context.env.AUTH0_DOMAIN,
context.env.AUTH0_AUDIENCE,
);
// Pass verified claims to downstream functions.
context.data.user = claims;
return context.next();
} catch {
return new Response("Unauthorized", { status: 401 });
}
};
ファイルベースのミドルウェアルーティング
Pages Functions は実際の関数の前に実行される _middleware.ts ファイルをサポートしています:
functions/
├── auth/
│ ├── _middleware.ts # Auth check for all /auth/* routes
│ └── callback.ts
├── api/
│ ├── _middleware.ts # Auth check for all /api/* routes
│ └── data.ts
└── public/
└── health.ts # No auth needed
自前のセッショントークンを発行・検証する(HS256 + jose)
トークンが 自前のもの — ログイン後にバックエンドが発行するセッショントークン — の場合、JWKS は不要です。ひとつの共有シークレットを持ち、それを署名と検証の両方に使います(HS256)。jose ライブラリは Workers ランタイムで動作し、これを簡潔に書けます。
シークレットは wrangler.toml に書かず、wrangler secret put SESSION_SECRET で設定してください。
// functions/lib/session.ts
import { SignJWT, jwtVerify } from "jose";
export interface SessionPayload {
sub: string;
type: "access" | "refresh";
[key: string]: unknown;
}
export async function createToken(
payload: SessionPayload,
secret: string,
expiresIn: string,
): Promise<string> {
const secretKey = new TextEncoder().encode(secret);
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(expiresIn)
.sign(secretKey);
}
export async function verifyToken(
token: string,
secret: string,
expectedType: "access" | "refresh",
): Promise<SessionPayload> {
const secretKey = new TextEncoder().encode(secret);
const { payload } = await jwtVerify(token, secretKey);
const result = payload as unknown as SessionPayload;
if (result.type !== expectedType) {
throw new Error(`Expected token type "${expectedType}" but got "${result.type}"`);
}
return result;
}
jwtVerify は署名 に加えて exp クレームも検証してくれるので、自前トークンの検証も完全にローカルで完結します — ネットワーク通信はまったく発生しません。
CORS ヘッダー
異なるドメインから呼び出される API 関数の場合:
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export const onRequestOptions: PagesFunction = async () => {
return new Response(null, { headers: corsHeaders });
};
export const onRequestGet: PagesFunction<Env> = async (context) => {
// ... your logic
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
...corsHeaders,
},
});
};