zudo-cloudflare-wisdom

Type to search...

to open search from anywhere

HTTP-only Cookie セッション

作成2026年5月28日Takeshi Takatsudo

Workers での HTTP-only Cookie セッションとリフレッシュトークンのローテーション

概要

Workers には Cookie ヘルパーがありません。ランタイムが渡してくるのは生の Set-Cookie 文字列と生の Cookie リクエストヘッダーだけで、パースもシリアライズもしてくれません。そのため両方向を自分で組み立てることになり、属性(HttpOnlySecureSameSiteDomainMax-Age)を正しく付けることがセキュリティ上きわめて重要です。HttpOnly が抜ければセッションが JavaScript に露出し、Secure が抜ければ平文 HTTP で漏れます。

このページでは、短命なアクセス Cookie と長命なリフレッシュ Cookie という 2 つのトークンを土台にした HTTP-only Cookie セッションを扱います。サーバー側でローテーションすることで、再ログインなしにアクセス Cookie を静かに発行し直せます。

Workers に res.cookie() はありません。Set-Cookie の値を文字列として組み立て、自分でレスポンスヘッダーに追加します。

// utils/cookies.ts
export interface CookieOptions {
  httpOnly?: boolean;
  secure?: boolean;
  sameSite?: 'Strict' | 'Lax' | 'None';
  path?: string;
  maxAge?: number;
  domain?: string;
}

export function parseCookies(cookieHeader: string): Record<string, string> {
  const cookies: Record<string, string> = {};
  if (!cookieHeader) {
    return cookies;
  }
  const pairs = cookieHeader.split(';');
  for (const pair of pairs) {
    const trimmed = pair.trim();
    if (!trimmed) continue;
    const eqIndex = trimmed.indexOf('=');
    if (eqIndex === -1) continue;
    const key = trimmed.substring(0, eqIndex).trim();
    const value = trimmed.substring(eqIndex + 1).trim();
    cookies[key] = value;
  }
  return cookies;
}

export function serializeCookie(name: string, value: string, options: CookieOptions): string {
  const parts: string[] = [`${name}=${value}`];

  if (options.httpOnly) {
    parts.push('HttpOnly');
  }
  if (options.secure) {
    parts.push('Secure');
  }
  if (options.sameSite) {
    parts.push(`SameSite=${options.sameSite}`);
  }
  if (options.path) {
    parts.push(`Path=${options.path}`);
  }
  if (options.maxAge !== undefined) {
    parts.push(`Max-Age=${options.maxAge}`);
  }
  if (options.domain) {
    parts.push(`Domain=${options.domain}`);
  }

  return parts.join('; ');
}

⚠️ 属性はどれも重要

HttpOnlydocument.cookie からのアクセスを遮断します(XSS でトークンを読めない)。Secure は Cookie を HTTPS に限定します。SameSite はクロスサイト送信を制御します(クロスオリジンのリンクを持つ同一サイトのアプリでは Lax が実用的なデフォルト)。これらを省くとセッションが静かに弱体化します。安全なデフォルトを補ってくれるフレームワークは存在しません。

2 トークンモデル

長命なセッション Cookie が 1 つだけというのはリスクです。漏れれば、その有効期間まるごと使えてしまいます。解決策は 2 つのトークンに分けることです。

  • アクセストークン — 短命(maxAge: 900 = 15 分)。毎リクエストに送られ、ユーザー識別情報を持つ。
  • リフレッシュトークン — 長命。リフレッシュエンドポイントにのみ送られ、新しいアクセストークンの発行だけに使う。

どちらも同じシークレットで署名された JWT です。両者が交換可能にならないようにしているのは、各トークンに焼き込まれたサーバー側の type クレーム(accessrefresh)であり、検証時に強制されます。

// utils/jwt.ts
import { SignJWT, jwtVerify, decodeJwt } from 'jose';

import type { TokenPayload } from '../types/auth.js';

export async function createToken(
  payload: Omit<TokenPayload, 'iat' | 'exp'>,
  secret: string,
  expiresIn: string,
): Promise<string> {
  const secretKey = new TextEncoder().encode(secret);
  const token = await new SignJWT({ ...payload })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime(expiresIn)
    .sign(secretKey);
  return token;
}

export async function verifyToken(
  token: string,
  secret: string,
  expectedType: 'access' | 'refresh',
): Promise<TokenPayload> {
  const secretKey = new TextEncoder().encode(secret);
  const { payload } = await jwtVerify(token, secretKey);
  const tokenPayload = payload as unknown as TokenPayload;
  if (tokenPayload.type !== expectedType) {
    throw new Error(`Expected token type "${expectedType}" but got "${tokenPayload.type}"`);
  }
  return tokenPayload;
}

🚨 type クレームが重要な理由

type チェックがなければ、長命なリフレッシュトークンがアクセストークンとしても検証を通ってしまいます。リフレッシュトークンを奪った攻撃者は、それをそのままセッション資格情報として有効期間いっぱい使えてしまいます。if (tokenPayload.type !== expectedType) throw の 1 行こそが、リフレッシュトークンをローテーションエンドポイント経由に限定し、アクセストークンとして振る舞わせないようにしています。

リフレッシュフロー

リフレッシュエンドポイントは refresh_token Cookie を読み取り、それが本当に refresh トークンであることを検証し、新しい短命なアクセス Cookie を発行します。Cookie の Max-Age900)は JWT の '15m' 有効期限に意図的に合わせてあり、Cookie とトークンが同時に切れるようにしています。

// handlers/refresh.ts
import type { Env } from '../index.js';
import { parseCookies, serializeCookie } from '../utils/cookies.js';
import { createToken, verifyToken } from '../utils/jwt.js';

export async function handleRefresh(request: Request, env: Env): Promise<Response> {
  const cookieHeader = request.headers.get('cookie') || '';
  const cookies = parseCookies(cookieHeader);
  const refreshToken = cookies['refresh_token'];

  if (!refreshToken) {
    return new Response(
      JSON.stringify({
        error: 'Unauthorized',
        message: 'No refresh token',
      }),
      {
        status: 401,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }

  try {
    const payload = await verifyToken(refreshToken, env.JWT_SECRET, 'refresh');

    const newAccessToken = await createToken(
      {
        sub: payload.sub,
        email: payload.email,
        name: payload.name,
        picture: payload.picture,
        type: 'access',
      },
      env.JWT_SECRET,
      '15m',
    );

    const accessCookie = serializeCookie('access_token', newAccessToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'Lax',
      path: '/',
      maxAge: 900,
      domain: env.COOKIE_DOMAIN,
    });

    return new Response(JSON.stringify({ success: true }), {
      status: 200,
      headers: new Headers([
        ['Content-Type', 'application/json'],
        ['Set-Cookie', accessCookie],
      ]),
    });
  } catch {
    return new Response(
      JSON.stringify({
        error: 'Unauthorized',
        message: 'Invalid refresh token',
      }),
      {
        status: 401,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }
}

クライアントはトークンに一切触れません。HttpOnly なので、ブラウザが自動で保存・送信します。アクセス Cookie が切れたときは、リフレッシュ Cookie がまだ有効であるかぎり、リフレッシュエンドポイントへのリクエストが透過的に新しいものを発行します。

サブドメイン間でのセッション共有

Cookie に Domain= を設定すると、そのドメインの全サブドメインに送られるようになります。値は COOKIE_DOMAIN バインディングから取得し、環境ごとの値を保ちます(例: .example.comapp.example.comapi.example.com などの間でセッションを共有します)。

const accessCookie = serializeCookie('access_token', newAccessToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'Lax',
  path: '/',
  maxAge: 900,
  domain: env.COOKIE_DOMAIN,
});

Wrangler 設定: シークレットと vars

COOKIE_DOMAIN は公開設定なので [vars] で問題ありません。JWT_SECRET は全トークンの署名鍵であり、絶対に wrangler.toml に置いてはいけません。[vars] ではプレースホルダーとして空のままにし、実際の値はシークレットとして注入します。

name = "auth-worker"
main = "src/index.ts"
compatibility_date = "2024-12-01"

[vars]
AUTH0_DOMAIN = ""
AUTH0_CLIENT_ID = ""
AUTH0_CLIENT_SECRET = ""
JWT_SECRET = ""
APP_URL = ""
COOKIE_DOMAIN = ""

🚨 シークレットと Vars

[vars] の空の JWT_SECRET はローカルの型付け用のプレースホルダーにすぎません。実際の値は wrangler secret put JWT_SECRET で設定し、コミットしません。JWT シークレットを握った者は、任意のユーザーになりすました正当なアクセストークン・リフレッシュトークンを偽造できます。

関連項目