Set-CookieのMax-Age=1wは期限なしになる理由とChrome実測

Set-Cookie: session=abc; Max-Age=1w と書くと、1週間有効なクッキーにはならない。1w は整数ではないので Max-Age 属性ごと無視され、ブラウザを閉じると消えるセッションクッキーになる。エラーも警告も出ないので、「ログインが1週間もたない」という形でしか表に出ない。

この記事では、Max-Age と Expires の解釈を RFC 6265 と、その改訂版の draft-ietf-httpbis-rfc6265bis で確認し、16通りの Set-Cookie を Chromium 152 に実際に保存させて、何秒の期限になったかを測る。仕様どおりにならなかったケースが2件あった。

仕様: Max-Age は「先頭が数字か - 」で「残りが全部数字」

RFC 6265 の 5.2.2 The Max-Age Attribute は、Max-Age の値を次の順で処理するとしている。

  1. 値の先頭が数字でも - でもなければ、その属性(cookie-av)を無視する
  2. 2文字目以降に数字以外の文字があれば、その属性を無視する
  3. 整数に変換し、0以下なら「表現できる最も早い日時」、つまり即時失効にする

改訂版の draft-ietf-httpbis-rfc6265bis-22(2025年12月1日版)の 5.6.2 も同じ手順で、先頭の条件が「数字、または - に続く数字」に少し厳しくなり、空の値は無視、と明記された。加えて、整数にした値を cookie-age-limit と比べて小さい方を使う手順が入った。5.5 はこの上限を「400日(34560000秒)より先にしない(SHOULD NOT)」としている。Expires にも同じ上限がかかる(5.6.1)。

Max-Age と Expires の両方があるときは Max-Age が勝つ。RFC 6265 の 4.1.2.2 にそう書かれており、保存の手順(RFC 6265 の 5.3、6265bis の 5.7)でも、Max-Age 属性があればそれで期限を決め、無いときだけ Expires を見る。同名の属性が複数あるときは、属性リストの最後のものを使う。

まとめると、仕様上は次のようになる。

  • 1w、60.5、"60"(引用符つき)、+60 は、どれも Max-Age 属性ごと無視される
  • 無視された結果 Max-Age も Expires も無ければ、セッションクッキーになる
  • 無視された Max-Age は属性リストに入らないので、Max-Age=7200; Max-Age=abc は 7200 になるはず

Chromium 152 で16通りを実測

Node.js v24.11.1 で localhost に小さなサーバーを立て、1回のレスポンスで16個の Set-Cookie を返した。ブラウザは Chromium 152(Chrome/152.0.7977.167、Windows 11)。保存されたクッキーの期限は、ページ側の JavaScript で Cookie Store API の cookieStore.getAll() を呼び、expires から現在時刻を引いて秒にした。expires が null ならセッションクッキー、一覧に無ければ保存されなかったと判定している。

Set-Cookie の属性 Chromium 152 の結果 仕様(6265bis-22)の期待
Max-Age=3600 3600秒 3600秒
Max-Age=1w セッション セッション
Max-Age=+60 60秒 セッション
Max-Age=60.5 セッション セッション
Max-Age=0 保存されない 保存されない
Max-Age=-1 保存されない 保存されない
Max-Age=60; Expires=<1日前> 60秒 60秒
Expires=<1日後>; Max-Age=0 保存されない 保存されない
Max-Age=99999999 34560000秒 34560000秒
Expires=Fri, 31 Dec 2100 00:00:00 GMT 34560000秒 34560000秒
Max-Age=60; Max-Age=7200 7200秒 7200秒
Max-Age=7200; Max-Age=abc セッション 7200秒
Max-Age="60" セッション セッション
Max-Age= 60 60秒 60秒
Expires=2026-12-01 セッション セッション
Max-Age=00060 60秒 60秒

読み取れることは次のとおり。

  • 整数でない Max-Age は、エラーにならずセッションクッキーになる。1w、60.5、"60" がそうだった。「1週間」「1分半」のつもりで書いても、ブラウザを閉じると消える。
  • Max-Age は書いた順番に関係なく Expires に勝つ。1日前の Expires があっても Max-Age=60 なら60秒残り、1日後の Expires があっても Max-Age=0 なら保存されない。
  • 400日の上限は実際にかかっている。約3年分の Max-Age=99999999 も、2100年の Expires も、どちらもちょうど34560000秒(400日)に切り詰められた。
  • Expires=2026-12-01 はセッションになった。RFC 6265 の 5.1.1 の日付解析は、時刻・日・月名・年がそろわないと失敗とする。- は区切り文字として扱われ、12 は月名(Dec など)ではなく、時刻も無いので解析に失敗し、Expires が無視される。ISO 8601 の日付は Cookie の日付としては読めない。

仕様と食い違ったのは太字の2件だった。

  • Max-Age=+60 は、仕様では先頭が数字でも - でもないので無視されるはずだが、Chromium 152 は60秒として受け入れた。
  • Max-Age=7200; Max-Age=abc は、仕様では abc だけが無視されて 7200 が残るはずだが、Chromium 152 はセッションクッキーにした。最後の Max-Age の値だけを見て、それが読めなければ Max-Age 全体を無かったことにしているように見える。

どちらも、送る側が正しい整数を1回だけ書いていれば起きない。ただ、フレームワークの設定とリバースプロキシの両方が Max-Age を足すような構成では2件目の形になりうるので、「後ろに壊れた値が付くと前の正しい値も消える」ことは覚えておきたい。Firefox と Safari では確かめていない。

実際に試す

Node.js があれば試せる(記事では v24.11.1 で確認)。ブラウザ側は Cookie Store API を使うので、Chromium 系のブラウザで開く。localhost は安全なコンテキスト扱いなので、HTTP のままで cookieStore が使える。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// server.js: Max-Age / Expires の解釈をブラウザで確かめるローカルサーバー
const http = require('node:http');

const past = new Date(Date.now() - 86400e3).toUTCString();
const future = new Date(Date.now() + 86400e3).toUTCString();

const cases = [
['c01', 'Max-Age=3600'],
['c02', 'Max-Age=1w'],
['c03', 'Max-Age=+60'],
['c04', 'Max-Age=60.5'],
['c05', 'Max-Age=0'],
['c06', 'Max-Age=-1'],
['c07', `Max-Age=60; Expires=${past}`],
['c08', `Expires=${future}; Max-Age=0`],
['c09', 'Max-Age=99999999'],
['c10', 'Expires=Fri, 31 Dec 2100 00:00:00 GMT'],
['c11', 'Max-Age=60; Max-Age=7200'],
['c12', 'Max-Age=7200; Max-Age=abc'],
['c13', 'Max-Age="60"'],
['c14', 'Max-Age= 60'],
['c15', 'Expires=2026-12-01'],
['c16', 'Max-Age=00060'],
];

const page = `<!doctype html><meta charset="utf-8"><title>cookie test</title><pre id="out">running</pre>
<script>
(async () => {
const now = Date.now();
const got = await cookieStore.getAll();
const map = Object.fromEntries(got.map(c => [c.name, c]));
const rows = ${JSON.stringify(cases)}.map(([n, a]) => {
const c = map[n];
if (!c) return [n, a, 'not stored'];
if (c.expires == null) return [n, a, 'session'];
return [n, a, Math.round((c.expires - now) / 1000) + 's'];
});
document.getElementById('out').textContent = navigator.userAgent + '\\n' + rows.map(r => r.join(' | ')).join('\\n');
})();
</script>`;

http.createServer((req, res) => {
if (req.url === '/set') {
res.setHeader('Set-Cookie', cases.map(([n, a]) => `${n}=v; Path=/; ${a}`));
res.setHeader('Location', '/read');
res.statusCode = 302;
return res.end();
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(page);
}).listen(8765, () => console.log('http://localhost:8765/set'));

node server.js で起動し、ブラウザで http://localhost:8765/set を開く。/set がクッキーを付けて /read にリダイレクトし、/read が保存結果を表示する。Chromium 152 での表示は次のとおり(Expires の日付は実行時刻で変わる)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
c01 | Max-Age=3600 | 3600s
c02 | Max-Age=1w | session
c03 | Max-Age=+60 | 60s
c04 | Max-Age=60.5 | session
c05 | Max-Age=0 | not stored
c06 | Max-Age=-1 | not stored
c07 | Max-Age=60; Expires=Fri, 30 Oct 2026 14:47:04 GMT | 60s
c08 | Expires=Sun, 01 Nov 2026 14:47:04 GMT; Max-Age=0 | not stored
c09 | Max-Age=99999999 | 34560000s
c10 | Expires=Fri, 31 Dec 2100 00:00:00 GMT | 34560000s
c11 | Max-Age=60; Max-Age=7200 | 7200s
c12 | Max-Age=7200; Max-Age=abc | session
c13 | Max-Age="60" | session
c14 | Max-Age= 60 | 60s
c15 | Expires=2026-12-01 | session
c16 | Max-Age=00060 | 60s

サーバーが実際に送ったヘッダーは curl -i http://localhost:8765/set で確認できる。2回目以降に試すときは、前回のクッキーが残っているので、開発者ツールの Application タブで localhost のクッキーを消してから開く。

2. 仕様どおりの Max-Age 判定を書く

サーバー側で Set-Cookie を組み立てるときや、ログに残ったヘッダーを調べるときのために、6265bis-22 の 5.6.2 と 5.7 の手順どおりに Max-Age を判定する関数を書いた。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// max-age.js: RFC 6265bis 5.6.2 / 5.7 の手順どおりに Max-Age を解釈する
const LIMIT = 400 * 24 * 60 * 60; // 34560000 秒

function maxAgeOf(setCookie) {
let last = null; // 有効だった Max-Age のうち最後のもの
for (const part of setCookie.split(';').slice(1)) {
const i = part.indexOf('=');
const name = (i < 0 ? part : part.slice(0, i)).trim().toLowerCase();
const value = i < 0 ? '' : part.slice(i + 1).trim();
if (name !== 'max-age') continue;
if (!/^-?\d/.test(value)) continue; // 先頭が数字か「-と数字」でなければ無視
if (/\D/.test(value.slice(1))) continue; // 2文字目以降に数字以外があれば無視
last = Math.min(parseInt(value, 10), LIMIT);
}
if (last === null) return 'Max-Age なし(Expires を見る)';
return last <= 0 ? '削除(保存しない)' : `${last}s`;
}

const inputs = [
'a=v; Max-Age=3600',
'a=v; Max-Age=1w',
'a=v; Max-Age=+60',
'a=v; Max-Age=60.5',
'a=v; Max-Age=-1',
'a=v; Max-Age=99999999',
'a=v; Max-Age=60; Max-Age=7200',
'a=v; Max-Age=7200; Max-Age=abc',
'a=v; Max-Age="60"',
'a=v; Max-Age= 60',
];
for (const s of inputs) console.log(s.padEnd(34), maxAgeOf(s));
1
2
3
4
5
6
7
8
9
10
11
$ node max-age.js
a=v; Max-Age=3600 3600s
a=v; Max-Age=1w Max-Age なし(Expires を見る)
a=v; Max-Age=+60 Max-Age なし(Expires を見る)
a=v; Max-Age=60.5 Max-Age なし(Expires を見る)
a=v; Max-Age=-1 削除(保存しない)
a=v; Max-Age=99999999 34560000s
a=v; Max-Age=60; Max-Age=7200 7200s
a=v; Max-Age=7200; Max-Age=abc 7200s
a=v; Max-Age="60" Max-Age なし(Expires を見る)
a=v; Max-Age= 60 60s

属性値の前後の空白は、属性の解析の段階で取り除かれるので、Max-Age= 60 は60秒になる(Chromium でも60秒だった)。この関数と1の実測を見比べると、+60 と 7200; abc の2行だけが食い違う。送る側としては、どちらの解釈でも同じ結果になるよう、Max-Age は符号なしの整数で1回だけ書くのが安全である。

ブラウザで使える Set-Cookie 解析(ハシトシステム) は、貼り付けた Set-Cookie の期限が Max-Age と Expires のどちらで決まったかを表示する。2026-10-31 時点のコードでは、Max-Age を /^-?\d+$/ で判定して整数でなければエラーを出すので、Max-Age=1w や 60.5 が無視されることはこのツールで確かめられる。

一方、今回の実測と比べると、次の点はツールの表示とブラウザの実際の挙動が一致しない。

  • Expires の解釈に JavaScript の Date.parse を使っているため、Expires=2026-12-01 を有効な日付として読む。Chromium 152 は Cookie の日付として読めずセッションクッキーにした。
  • 400日の上限は反映しておらず、Max-Age=99999999 はそのままの秒数で表示される。
  • Max-Age=+60 は仕様どおり「読めない」と表示するが、Chromium 152 は60秒として受け入れた。

ツールの結果は「仕様上の解釈」として読み、ブラウザでの最終的な結果は上の server.js のような実測で確かめるのが確実である。

SameSite=None と CORS の組み合わせについては CVE-2025-34291 に学ぶ CORS と SameSite=None の記事、HTTP ヘッダーの文字コードの話は Basic 認証の btoa が日本語で落ちる記事 にも書いた。

まとめ

  • Max-Age の値は「先頭が数字(6265bis では - と数字も可)、残りが全部数字」でなければ、属性ごと無視される。1w、60.5、"60" はセッションクッキーになる。
  • Max-Age と Expires の両方があれば、順番に関係なく Max-Age が勝つ。
  • 6265bis では期限の上限が400日(34560000秒)で、Chromium 152 は Max-Age にも Expires にもこの上限をかけた。
  • Expires=2026-12-01 のような ISO 8601 の日付は Cookie の日付として読めず、無視される。
  • Chromium 152 は Max-Age=+60 を受け入れ、Max-Age=7200; Max-Age=abc をセッションにした。どちらも仕様の手順とは異なるので、Max-Age は符号なしの整数で1回だけ書く。

参考

  • RFC 6265 HTTP State Management Mechanism: 4.1.2.2 The Max-Age Attribute、5.1.1 Dates、5.2.2 The Max-Age Attribute、5.3 Storage Model
  • draft-ietf-httpbis-rfc6265bis-22 Cookies: HTTP State Management Mechanism: 5.5(クッキーの期限の上限)、5.6.1 The Expires Attribute、5.6.2 The Max-Age Attribute、5.7 Storage Model

Set-CookieのMax-Age=1wは期限なしになる理由とChrome実測
https://blog.hashito.biz/2026/10/31/set-cookie-max-age-invalid-session-400-days-chromium/
著者
hashito
作成日
2026年10月31日
著作権