紀錄工作經驗、相關知識,解決技術相關問題。

新手 Jamp Holes, 網站相關

Session Restore 是什麼?瀏覽器還原分頁時 Session Cookie 為什麼沒消失

Session Restore 是什麼?瀏覽器還原分頁時 Session Cookie 為什麼沒消失 特色圖片

Session Restore 是什麼? 它是瀏覽器的「還原上次工作階段」功能:重新開啟瀏覽器時,把上次開著的視窗與分頁照原樣打開。麻煩的地方在於,MDN 明講 Session Cookie 也會跟著還原,所以「關掉瀏覽器,登入就會消失」這個直覺不一定成立。本文先說明各瀏覽器怎麼定義還原,接著用無頭 Chrome 重現 Session Cookie 被還原的過程,最後整理網站開發者與一般使用者各自該怎麼處理。

Cookie 的 Expires 欄位為什麼會顯示 Session、要怎麼設定到期時間,可前往〈Cookie Expires 顯示為 Session 是什麼意思?原因與設定教學〉觀看,本篇只談「還原」這一段。

Session Restore 是什麼:瀏覽器怎麼把上次的分頁帶回來

各家瀏覽器都有這個功能,只是名稱與設定位置不同:

  • Chrome:在「啟動時(On startup)」選擇「Continue where you left off」。Google 的說明是:可以讓 Chrome 重新開啟你離開時正在看的同一批頁面;同一頁說明也提到,Cookie 與資料會被保留,所以先前登入過的網站再開仍是登入狀態。
  • Firefox:Firefox 原始碼文件把 Session Restore 描述為追蹤使用者開著或最近關閉的視窗、分頁,並在啟動時讀取 session 檔、決定是否還原上一次的工作階段。
  • Chromium 本身:原始碼裡有一個 --restore-last-session 命令列旗標,註解寫的是「啟動時還原上一次的工作階段,並覆寫偏好設定的值」。後面的範例會用它在不動使用者設定的情況下,模擬「Continue where you left off」。

重點是:還原的不只是網址。MDN 的 Set-Cookie 文件有一則警告,說許多瀏覽器的 session restore 會存下所有分頁並在下次使用時還原,Session Cookie 也會被還原,彷彿瀏覽器從未關閉。

Session Cookie 為什麼會被還原

沒有設定 Expires 或 Max-Age 的 Cookie 是 Session Cookie。MDN 的說法是:session 在用戶端(client)關閉時結束,之後 Session Cookie 會被移除。RFC 6265 的寫法更精確:使用者代理(user agent,也就是瀏覽器)會保留這個 Cookie,直到「目前的 session 結束」,而什麼時候算結束,由使用者代理自己定義。

這兩句合起來就能解釋現象:規格沒有規定「關視窗=session 結束」,是瀏覽器自己決定。開啟 Session Restore 之後,瀏覽器把「關閉再開啟」視為同一個 session 的延續,Session Cookie 就沒有被清掉。

Firefox 的原始碼也能對上這件事:負責 session store 的 SessionCookies 模組有一行註解寫著「只儲存 session cookie,並遵守隱私等級」,也就是 Session Cookie 本來就是 session store 要保存、還原時要寫回的資料。

Chrome DevTools 的 Application 分頁看 Cookie 時,Session Cookie 的 Expires / Max-Age 欄位一律顯示 Session。這個欄位只代表「沒有設定到期時間」,不代表「這個 Cookie 一定在關閉瀏覽器時消失」。

用無頭 Chrome 重現一次:還原前後 Session Cookie 還在不在

下面的程式用 Node.js 起一個本機伺服器,每次請求都發一個 remember(有 Max-Age)的 Cookie;如果請求裡沒有 sid,才另外發一個沒有到期時間的 Session Cookie sid,值是遞增編號。頁面會直接顯示伺服器收到的 Cookie 標頭與這次有沒有發新的 sid。

用 Playwright 開一個暫存設定檔,造訪頁面、關閉,再用同一個設定檔重新啟動:plain 組什麼都不加,restore 組加上 --restore-last-session。

const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { chromium } = require('playwright');

let counter = 0;
const log = [];
const server = http.createServer((req, res) => {
  if (req.url !== '/') { res.statusCode = 404; return res.end(); }
  const received = req.headers.cookie || '(沒有 Cookie 標頭)';
  const hasSid = /(^|;\s*)sid=/.test(req.headers.cookie || '');
  const headers = ['remember=keep-me; Path=/; Max-Age=3600'];
  let issued = '否,沿用瀏覽器送來的 sid';
  if (!hasSid) {
    counter += 1;
    headers.push('sid=issued-' + counter + '; Path=/');
    issued = '是,發出 sid=issued-' + counter;
  }
  log.push({ received, issued });
  res.setHeader('Set-Cookie', headers);
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.end(`<!doctype html><html lang="zh-TW"><meta charset="utf-8"><title>Cookie 檢查</title>
<body style="font:16px/1.7 system-ui;margin:0;padding:16px;width:560px">
<div id="box" style="border:1px solid #888;border-radius:8px;padding:16px">
<p style="margin:0"><b>伺服器收到的 Cookie 標頭</b></p>
<pre style="margin:4px 0 12px">${received}</pre>
<p style="margin:0"><b>這次回應是否發出新的 sid</b></p>
<pre style="margin:4px 0 0">${issued}</pre>
</div></body></html>`);
});

async function launch(profile, args) {
  return chromium.launchPersistentContext(profile, {
    channel: 'chrome', headless: true, viewport: { width: 600, height: 300 }, args,
  });
}

(async () => {
  await new Promise(r => server.listen(4777, '127.0.0.1', r));
  const out = {};
  for (const mode of ['plain', 'restore']) {
    const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'sr-' + mode + '-'));
    let ctx = await launch(profile, []);
    let page = ctx.pages()[0] || await ctx.newPage();
    await page.goto('http://127.0.0.1:4777/');
    out[mode + '_first'] = (await ctx.cookies('http://127.0.0.1:4777/'))
      .map(c => `${c.name} expires=${c.expires === -1 ? 'Session' : 'set'}`);
    await ctx.close();

    const before = log.length;
    ctx = await launch(profile, mode === 'restore' ? ['--restore-last-session'] : []);
    await new Promise(r => setTimeout(r, 2000));
    if (mode === 'plain') {
      page = ctx.pages()[0] || await ctx.newPage();
      out.plain_restored_tabs = ctx.pages().map(p => p.url());
      await page.goto('http://127.0.0.1:4777/');
    } else {
      out.restore_restored_tabs = ctx.pages().map(p => p.url());
      page = ctx.pages().find(p => p.url().startsWith('http://127.0.0.1')) || ctx.pages()[0];
      out.restore_cookies = (await ctx.cookies('http://127.0.0.1:4777/'))
        .map(c => c.name + ' expires=' + (c.expires === -1 ? 'Session' : 'set'));
      await page.reload();
    }
    out[mode + '_second_requests'] = log.slice(before);
    await ctx.close();
    fs.rmSync(profile, { recursive: true, force: true });
  }
  console.log(JSON.stringify(out, null, 2));
  server.close();
})();

執行結果整理如下(Chrome 158、Windows 11、Playwright 1.64.1,每組都用全新的暫存設定檔):

組別第二次啟動後的分頁第二次啟動後的 Cookie伺服器收到的 Cookie 標頭
plain(不加旗標)只有 about:blank只剩 rememberremember=keep-me,所以重新發出新的 sid=issued-2
restore(加 --restore-last-session)http://127.0.0.1:4777/ 被還原remember、sid(Expires 顯示 Session)都在remember=keep-me; sid=issued-3,沒有發新的 sid

plain 組的畫面如下,Session Cookie sid 已經不在,伺服器只收到 remember,於是重新發了一個:

不啟用還原時,重新啟動後伺服器只收到 remember,並重新發出 sid=issued-2

Chrome 158、Windows 11,全新暫存設定檔重新啟動後的頁面內容。

restore 組的 sid=issued-3 是這組第一次啟動時發出的值(編號是整個腳本共用的遞增計數),重新啟動後瀏覽器原封不動帶回來,所以這次回應「沒有發新的 sid」:

啟用還原時,重新啟動後伺服器收到 remember 與 sid=issued-3,沒有發新的 sid

Chrome 158、Windows 11,使用 --restore-last-session 重新啟動、重新整理被還原的分頁後的頁面內容。

這個範例有幾個限制要注意:它是 Playwright 以 context.close() 結束瀏覽器,不等於使用者按視窗右上角的關閉鈕或系統關機;用的是旗標而不是設定頁的選項(依原始碼註解,旗標會覆寫偏好設定值);也只代表這個版本的 Chrome,不代表 Firefox 或 Safari 的行為。

網站開發者要注意什麼

1. 不要用「Session Cookie」當成「關閉瀏覽器就登出」的保證

使用者開啟了還原功能時,瀏覽器重新啟動後會把 Session Cookie 帶回來,登入狀態就會延續。MDN 對 session 登入流程的描述是:伺服器回傳含 session ID 的 Cookie,之後瀏覽器對該網站的請求自動帶上它,由伺服器檢查這個 session ID 是否仍然有效。所以真正決定「還算不算登入」的是伺服器:需要時間限制的網站,應該在伺服器端記錄 session 的有效期限並在請求時檢查,而不是只靠 Cookie 有沒有被瀏覽器清掉。

2. 想讓 Cookie 在固定時間後失效,就明確設定 Max-Age 或 Expires

MDN 說明 Max-Age 是 Cookie 到期前的秒數,0 或負數會讓 Cookie 立即過期,Expires 與 Max-Age 同時存在時以 Max-Age 為準。設定方式與前後端範例請見導流的那篇。

3. sessionStorage 也有同樣的現象

MDN 說明 sessionStorage 的頁面工作階段在分頁或瀏覽器開著時持續,重新整理與還原後仍保留,關閉分頁或視窗才結束並清除資料。三種前端儲存的差異可前往〈前端資料儲存 Cookie、localStorage、sessionStorage 使用場景、限制、差異〉觀看。

使用者想避免被還原影響時

最直接的做法是使用網站自己的登出功能,而不是靠關閉瀏覽器。

Chrome 的說明頁列出了另一個選項:想讓「Continue where you left off」還原出來的網站不要自動登入,可以到「設定 > 隱私權和安全性 > 網站設定 > 其他內容設定 > 裝置上的網站資料」,開啟「Delete data sites have saved on your device when you close all windows」(介面語言不同,選項名稱會不同)。該頁沒有逐項說明這個選項刪除哪些資料,所以不要把它當成「Cookie 一定全部清掉」的保證。

常見問題

關閉分頁,Session Cookie 就會消失嗎?

不一定。MDN 寫的是 session 在「用戶端關閉」時結束,不是分頁關閉時結束;RFC 6265 則把「session 何時結束」交給瀏覽器自己定義。實際行為要看瀏覽器與設定。

為什麼還原之後,DevTools 還是顯示 Session?

因為這個欄位只標示「沒有設定 Expires 或 Max-Age」。上面的範例中,restore 組還原後 sid 的 Expires 仍然是 Session,但 Cookie 本身還在。

登入狀態在重新啟動後還在,是 Session Restore 的問題嗎?

先確認伺服器是否仍認得該 session ID。還原只是把 Cookie 與分頁帶回來;登入有沒有效,是伺服器檢查 session ID 的結果。

參考資料與延伸閱讀

發佈留言