zudo-cloudflare-wisdom

Type to search...

to open search from anywhere

Pages Functions での認証

作成2026年4月4日更新2026年5月28日Takeshi Takatsudo

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_SECRETwrangler 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 から成ります。検証とは次のことです:

  1. ヘッダーから kid(鍵 ID)を読み取る。
  2. IdP の JWKS から一致する公開鍵を探す。
  3. その鍵で header.payload に対する署名を検証する。
  4. issaudexp のクレームを検証する。

モジュールレベルのキャッシュ付き 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,
    },
  });
};