HTTP-only Cookie セッション
Workers での HTTP-only Cookie セッションとリフレッシュトークンのローテーション
概要
Workers には Cookie ヘルパーがありません。ランタイムが渡してくるのは生の Set-Cookie 文字列と生の Cookie リクエストヘッダーだけで、パースもシリアライズもしてくれません。そのため両方向を自分で組み立てることになり、属性(HttpOnly・Secure・SameSite・Domain・Max-Age)を正しく付けることがセキュリティ上きわめて重要です。HttpOnly が抜ければセッションが JavaScript に露出し、Secure が抜ければ平文 HTTP で漏れます。
このページでは、短命なアクセス Cookie と長命なリフレッシュ Cookie という 2 つのトークンを土台にした HTTP-only Cookie セッションを扱います。サーバー側でローテーションすることで、再ログインなしにアクセス 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('; ');
}属性はどれも重要
HttpOnly は document.cookie からのアクセスを遮断します(XSS でトークンを読めない)。Secure は Cookie を HTTPS に限定します。SameSite はクロスサイト送信を制御します(クロスオリジンのリンクを持つ同一サイトのアプリでは Lax が実用的なデフォルト)。これらを省くとセッションが静かに弱体化します。安全なデフォルトを補ってくれるフレームワークは存在しません。
2 トークンモデル
長命なセッション Cookie が 1 つだけというのはリスクです。漏れれば、その有効期間まるごと使えてしまいます。解決策は 2 つのトークンに分けることです。
アクセストークン -- 短命(
maxAge: 900= 15 分)。毎リクエストに送られ、ユーザー識別情報を持つ。リフレッシュトークン -- 長命。リフレッシュエンドポイントにのみ送られ、新しいアクセストークンの発行だけに使う。
どちらも同じシークレットで署名された JWT です。両者が交換可能にならないようにしているのは、各トークンに焼き込まれたサーバー側の type クレーム(access か refresh)であり、検証時に強制されます。
// 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-Age(900)は 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.com は app.example.com・api.example.com などの間でセッションを共有します)。
const accessCookie = serializeCookie('access_token', newAccessToken, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/',
maxAge: 900,
domain: env.COOKIE_DOMAIN,
});CSRF: Cookie はアンビエントな認証情報
ブラウザは、一致するオリジン宛てのリクエストであれば Cookie ヘッダーを自動的に付与します。そのリクエストをどのページが発生させたかは関知せず、宛先のオリジンだけを見ます。この利便性こそが、Cross-Site Request Forgery(CSRF)の前提そのものです。evil.example 上のページが被害者のブラウザに POST https: を発火させれば、access_token / refresh_token の各 Cookie は攻撃者がその値を一切見ることなく、認証済みのまま乗ってしまいます。
ゲートはブラウザ Cookie 経由のルートに限定します。 このチェックは、このページで扱っているアンビエント Cookie で認証されるルートの前にのみ置くべきものです。Authorization ヘッダー中のベアラートークンで認証するルートには不要です。そのヘッダーを付けるのは呼び出し側コードが意図的に行う作業(ストレージからトークンを読み出し、ヘッダーにセットする)であり、別オリジンのページがブラウザにそれを代行させる手段はありません。CSRF が突くのは「アンビエントな」認証情報の自動付与であり、ヘッダー経由の認証情報はそもそもアンビエントではありません。
ゲートの中身は、それぞれ特定の穴をふさぐいくつかのルールから成ります。
安全なメソッドはスキップします。 ゲートするのは
POST/PUT/PATCH/DELETEだけです。クロスサイトのページは<img src>・<link>・EventSourceなどで自オリジンへのGETを発火させることができ、許可なしに Cookie が乗ります。それが許容されるのは、GETが副作用を持たないと定義されているからです。攻撃者はブラウザに被害者の身元で何かを「取得」させられますが、「変更」はできませんし、レスポンスを読むこともできません(CORS の許可がないため)。GETハンドラが何かを変更してしまえば、この前提は崩壊します。Origin allowlist はタプルとして比較します。
Originヘッダーはnew URL()でパースし、(scheme, host, port)を明示的な allowlist と比較します。文字列の前方一致や部分一致では絶対に比較しません。https:は文字列としては/ / app. example. com. evil. example https:から始まりますが、同一オリジンではありません。/ / app. example. com Originヘッダーがない場合は拒否します。 最近のブラウザは、同一オリジンかどうかを問わず、状態変更を伴う fetch/XHR/フォーム送信のすべてでOriginを送ります。状態変更リクエストにOriginヘッダーが一切ない場合、それは非常に古いブラウザか、ヘッダーを偽装したブラウザ以外のクライアントです。Refererにフォールバックしたり同一オリジンとみなしたりせず、拒否します。Origin: nullは明示的に拒否します。 サンドボックス化された iframe(allow-same-originなしのsandbox)や一部のリダイレクトチェーン、file:のページは、いずれも文字列そのものの/ / "null"を Origin として送ってきます。これが allowlist に正当にマッチすることはありませんが、緩い正規表現やincludes()による比較では、これに騙されることがあります。比較ロジックに到達する前に、名指しで拒否します。Sec-Fetch-Site は補助シグナルであり代替ではありません。
Sec-Fetch-Site: same-originは多層防御として有用ですが、「site」は登録可能ドメイン(eTLD+1)のみを見ており、ポートは無視します。app.example.com:8787とapp.example.com:8788は別オリジンであり(上記の Origin allowlist ではそう扱う必要があります)、どちらもsame-siteと報告されます。この Cookie をDomain=.example.comでサブドメイン間共有している場合(上記参照)、Sec-Fetch-Siteだけを信頼すると、そのドメイン配下の どのポートのどのアプリ も信頼してしまうことになります。ポートまで含めて比較しなければならないのは Origin のタプル比較のほうであり、Sec-Fetch-Siteはその上に乗せる安価な追加チェックにすぎません。ゲートより先にセッションを検証します。 まず Cookie 自体を検証します。有効なセッションがなければ、CSRF チェックを走らせる前に即座に
401 Unauthorizedを返します -- まだ保護すべきアンビエントな認証情報が存在しないからです。セッションは有効だが CSRF チェックに失敗した場合は403 Forbiddenです。このステータスコードの違いが、クライアントにとっての問題の種類を伝えます。「ログインし直せ」なのか、「ログインはできているが、この特定のリクエストは拒否されている」なのか、という違いです。
// utils/csrf.ts
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
interface OriginTuple {
scheme: string;
host: string;
port: string;
}
function parseOrigin(value: string): OriginTuple | null {
try {
const url = new URL(value);
return {
scheme: url.protocol,
host: url.hostname,
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
};
} catch {
return null;
}
}
function isSameOrigin(a: OriginTuple, b: OriginTuple): boolean {
return a.scheme === b.scheme && a.host === b.host && a.port === b.port;
}
export function requiresCsrfCheck(request: Request): boolean {
return !SAFE_METHODS.has(request.method);
}
export function isTrustedOrigin(request: Request): boolean {
const origin = request.headers.get('Origin');
// Missing Origin on a state-changing request: refuse rather than fall
// back to Referer or assume same-origin.
if (!origin) {
return false;
}
// Sandboxed iframes, some redirect chains, and file:// pages send the
// literal string "null" -- it must never match the allowlist below.
if (origin === 'null') {
return false;
}
const parsed = parseOrigin(origin);
if (!parsed) {
return false;
}
for (const allowed of ALLOWED_ORIGINS) {
const parsedAllowed = parseOrigin(allowed);
if (parsedAllowed && isSameOrigin(parsed, parsedAllowed)) {
return true;
}
}
return false;
}状態変更ハンドラの中では、セッションチェックの後・状態変更の前にこれを組み込みます。ここでは上記のリフレッシュエンドポイントを例にします。
// handlers/refresh.ts (excerpt)
const payload = await verifyToken(refreshToken, env.JWT_SECRET, 'refresh');
// Reaching this line means the refresh token itself is valid -- an invalid
// or missing one throws and is caught by the existing 401 branch above.
// There is no ambient credential yet to protect until the session checks out.
if (requiresCsrfCheck(request) && !isTrustedOrigin(request)) {
return new Response(
JSON.stringify({
error: 'Forbidden',
message: 'Origin not allowed',
}),
{
status: 403,
headers: { 'Content-Type': 'application/json' },
},
);
}
// ...mint and return the new access cookie as beforeなぜ SameSite=Lax であって Strict ではないのか
上記のアクセス Cookie・リフレッシュ Cookie は sameSite: 'Lax' を使っており、Strict ではありません。Lax はクロスサイトの POST やサブリソースリクエスト(画像・iframe・fetch)では Cookie を送りませんが、GET によるトップレベルのクロスサイト ナビゲーション では送ります -- これはまさに OAuth プロバイダーがブラウザを / にリダイレクトして戻すときに起きることです。Strict だとその最初の着地で Cookie が落ちてしまい、ログインフローが壊れます。この代償として、上の「安全なメソッドはスキップします」のルールはオプションではありません。Lax が安全であり続けるのは、GET のルートが一切状態を変更しない場合に限られます。
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 シークレットを握った者は、任意のユーザーになりすました正当なアクセストークン・リフレッシュトークンを偽造できます。
関連項目
Pages Functions での認証 -- Pages Functions でのトークン検証と Auth0 連携パターン。