# ログインし直すと入力内容が消えてしまうセッション切れ

Source: https://easeweb.dev/learn/session-expiry
Topics: 制限時間と動き > セッション切れでデータが失われる前に、警告が出る (WCAG 2.2.6); 制限時間と動き > セッションが切れても、入力した内容が失われない (WCAG 2.2.5)
The fix: フォームをページから送信し、セッションが切れていたら、フォームの上のダイアログでパスワードを求めてから同じ内容を送り直します。または、セッションが切れた後に届いた内容をサーバーに1日保存し、同じアカウントでログインし直したらフォームに戻します。フォームを離れてよい時間を最初に伝えれば2.2.6は満たせますが、入力内容は失われたままです。
Test with: keyboard, screen reader, password manager
References: https://www.w3.org/WAI/WCAG22/Understanding/re-authenticating.html https://www.w3.org/WAI/WCAG22/Understanding/timeouts.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F12 https://www.w3.org/WAI/WCAG22/Techniques/general/G105 https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html

よくある問題を再現した学習用のサンプルです。実在のクライアントの診断結果ではありません。コードは出発点となる実装例のため、実際の製品でも確認してください。ここでは支援技術でのテスト結果は報告していません。

## 場面

保険会社の請求フォームには、証券番号と、事故の状況を自分の言葉で書く欄があります。台所の天井からの水漏れを説明するには時間がかかります。日付を調べたり、修理業者の領収書を探したり、途中で一息ついたりします。この保険会社は、セキュリティのため、サーバーへの最後のリクエストから30分で利用者をログアウトさせます。入力してもサーバーには何も送られないため、文章を書いている間も、30分の残り時間は減っていきます。制限があることは、フォームのどこにも書かれていません。40分後、利用者は「送信する」を押します。サーバーはセッションがないと分かると、送られた内容を捨ててログイン画面へリダイレクトします。画面には「セッションの有効期限が切れました。もう一度ログインしてください」と出ています。ログインし直すと請求フォームに戻りますが、フォームは空になっています。

これは、よく使われるフレームワークの既定の動きです。RailsのDevise（ログイン用のライブラリー）も、Laravelも、ログインし直した後に元のページへ戻しますが、覚えているのはページのアドレスだけで、そこへ送られた内容は覚えていません。Djangoも、ログイン画面へリダイレクトするときに残すのは `next` のパスだけです。Laravelでは、フォームのセキュリティトークンもセッションと一緒に切れるため、同じボタンを押すと、ログイン画面ではなく「419 Page Expired」の画面が出ることもよくあります。

## 確認できる問題

達成基準2.2.5「再認証」は、ログイン中のセッションが切れても、ログインし直した後に、データを失わずに作業を続けられることを求めています。制限そのものは認められています。2.2.5が求めているのはセッションを長くすることではなく、切れたときに入力した内容が失われないことです。W3Cの失敗例F12は、まさにこの形を挙げています。セッションが切れてログインし直すと、フォームに入力した内容が入っていないため、制限より長い時間が必要な人は、いつまでも入力を終えられません。

達成基準2.2.6「タイムアウト」は、どれだけ操作しないでいるとデータが失われるかを、利用者に知らせることを求めています。データを20時間より長く保存する場合は対象外です。W3Cは、この案内を作業の最初に一度出すよう求めています。そうすれば、先に書類をそろえるか、休憩を取るかを利用者が決められます。このフォームは何も書いておらず、何も残さないため、昼休みを取るだけで入力した内容がすべて失われます。どちらも、ブラウザーが原因ではありません。ページは普通のフォームを送っているだけで、セッションがなくなっていたときに入力内容を捨てるのは、サーバーの判断です。

## 利用者への影響

ある人には10分で書ける説明に、別の人は1時間かかることがあります。時間がかかる人ほど失うものも大きく、苦労して書いた部分をもう一度書くか、請求そのものをあきらめることになります。スマートフォンでは、写真や保険証券を探しに別のアプリへ切り替えている間も、サーバーには何も送られないため、その時間も制限に数えられます。

## 自動チェックとAIの限界

制限はサーバーの設定で、リダイレクトが起きるのは時間切れの後に送られたフォームだけです。スキャンには、ちゃんと動くフォームに見えます。スキャンはログインして30分待ってから送信したりはせず、送った内容をサーバーが残すかどうかはページのどこにも現れないため、EaseWebの診断にはこの失敗を見つけるルールがありません。実際に時間切れまで待った人だけが気づきます。フォームをログインしないと使えないようにしてほしいとAIアシスタントに頼むと、フレームワークそのままのリダイレクトを書くことがあります。これは下の修正前の例と同じで、セッションの制限を加えるときも、フォームの内容をどうするかには触れないことがあります。

## 修正前：送られた内容を捨てるログイン

```html claim.html
<form id="claim" method="post" action="/claims">
  <h1>保険金の請求</h1>
  <label for="policy">証券番号</label>
  <input id="policy" name="policy" value="{{ fields.policy }}">
  <label for="what">事故の状況</label>
  <textarea id="what" name="what" rows="8">{{ fields.what }}</textarea>
  <button>送信する</button>
</form>
```

```js server.js
// 最後のリクエストから30分でセッションを終える
app.use(session({ secret: process.env.SESSION_SECRET, rolling: true, cookie: { maxAge: 30 * 60 * 1000 } }));

// ログアウト後は、ログインしてから元のページへ戻す。送られた内容は捨てる
function signedIn(req, res, next) {
  if (req.session.user) return next();
  req.session.returnTo = req.method === 'GET' ? req.originalUrl : req.get('Referer');
  res.redirect('/sign-in');
}

app.get('/claims/new', signedIn, (req, res) => res.render('claim', { fields: {} }));
app.post('/claims', signedIn, saveClaim);

app.post('/sign-in', checkPassword, (req, res) => {
  const { returnTo } = req.session;
  req.session.regenerate(() => {
    req.session.user = req.user;
    req.session.save(() => res.redirect(returnTo ?? '/'));
  });
});
```

```js
// スクリプトはない。請求は普通のフォームとして送る
```

- 30分でログアウトされることも、そのとき入力した内容が失われることも、フォームのどこにも書かれていません。
- 入力してもサーバーには何も送られないため、30分書き続けても、30分離れていたのと同じに数えられます。
- その後に送った請求は捨てられ、ログイン後のために残るのはフォームのアドレスだけです。
- ログインし直すと、フォームは空で戻ってきます。

## 修正後：ページの上でログインし直し、同じ請求を送る

```html claim.html
<form id="claim" method="post" action="/claims">
  <h1>保険金の請求</h1>
  <label for="policy">証券番号</label>
  <input id="policy" name="policy" value="{{ fields.policy }}">
  <label for="what">事故の状況</label>
  <textarea id="what" name="what" rows="8">{{ fields.what }}</textarea>
  <button>送信する</button>
</form>

<dialog id="sign-in-again" aria-labelledby="sign-in-title">
  <form id="sign-in" method="post" action="/sign-in">
    <h2 id="sign-in-title">もう一度ログインして、請求を送信してください</h2>
    <p>入力した請求の内容は残っています。パスワードを入力すると送信します。</p>
    <input type="hidden" name="username" value="{{ user.email }}" autocomplete="username">
    <label for="password">パスワード</label>
    <input id="password" name="password" type="password" autocomplete="current-password">
    <button>ログインして送信</button>
  </form>
</dialog>
```

```js server.js
// 最後のリクエストから30分でセッションを終える
app.use(session({ secret: process.env.SESSION_SECRET, rolling: true, cookie: { maxAge: 30 * 60 * 1000 } }));

// ログアウト後、ページはログインへ移す。請求には401で答え、ページがログインを求める
function signedIn(req, res, next) {
  if (req.session.user) return next();
  if (req.method !== 'GET') return res.sendStatus(401);
  req.session.returnTo = req.originalUrl;
  res.redirect('/sign-in');
}

app.get('/claims/new', signedIn, (req, res) => res.render('claim', { fields: {} }));
app.post('/claims', signedIn, saveClaim);

app.post('/sign-in', checkPassword, (req, res) => {
  const { returnTo } = req.session;
  req.session.regenerate(() => {
    req.session.user = req.user;
    req.session.save(() => res.redirect(returnTo ?? '/'));
  });
});
```

```js
const claim = document.getElementById('claim');
const dialog = document.getElementById('sign-in-again');
const signIn = document.getElementById('sign-in');
const fields = (form) => new URLSearchParams(new FormData(form));

// 請求はページから送る。どんな応答でも、内容はフォームに残る
async function send() {
  const response = await fetch(claim.action, { method: 'POST', body: fields(claim) });
  if (response.status === 401) return dialog.showModal();
  if (!response.ok) return showSendError(response);
  location.href = '/claims/sent';
}
claim.addEventListener('submit', (event) => {
  event.preventDefault();
  send();
});

// ログインし直したら、ダイアログを閉じて同じ請求を送る
signIn.addEventListener('submit', async (event) => {
  event.preventDefault();
  const response = await fetch(signIn.action, { method: 'POST', body: fields(signIn) });
  if (!response.ok) return showSignInError(response);
  dialog.close();
  send();
});
```

1. 請求は `fetch` でページから送ります。サーバーがどう答えても、フォームはそのまま残ります。
2. セッションが切れていたら、リダイレクトではなく401で請求に答えます。これで、ページの側でログインを求めればよいと分かります。
3. フォームの上に、ログインのダイアログを `showModal()` で開きます。フォーカスはダイアログへ移ります。アカウントは入力済みなので、求めるのはパスワードだけで、パスワード管理ツールでも入力できます。
4. ログインできたらダイアログを閉じ、同じ請求をもう一度送ります。

これで修正前の例は直り、2.2.5と2.2.6を満たします。請求の内容はページに残り続けるため、どれだけ離れていても失われません。失われるのはタブを閉じたときだけで、W3Cはタブを閉じることをタイムアウトとして数えていません。ただし、スクリプトが必要で、ログインもページの中で済ませられなければなりません。別のサイトを通してログインするサイトは、次の修正か、下のシングルサインオンの項を参考にしてください。

## 修正後：ログインし直す間、送られた内容を残す

```html claim.html
<form id="claim" method="post" action="/claims">
  <h1>保険金の請求</h1>
  <input type="hidden" name="account" value="{{ user.id }}">
  <label for="policy">証券番号</label>
  <input id="policy" name="policy" value="{{ fields.policy }}">
  <label for="what">事故の状況</label>
  <textarea id="what" name="what" rows="8">{{ fields.what }}</textarea>
  <button>送信する</button>
</form>
```

```js server.js
// 最後のリクエストから30分でセッションを終える
app.use(session({ secret: process.env.SESSION_SECRET, rolling: true, cookie: { maxAge: 30 * 60 * 1000 } }));

// ログアウト後は、ログインし直す間、送られた内容を1日残す
function signedIn(req, res, next) {
  if (req.session.user) return next();
  if (req.method === 'POST') {
    req.session.kept = req.body;
    req.session.cookie.maxAge = 24 * 60 * 60 * 1000;
  }
  req.session.returnTo = req.method === 'GET' ? req.originalUrl : (req.get('Referer') ?? '/claims/new');
  res.redirect('/sign-in');
}

// 残した内容があれば、それを入れてフォームを表示する
app.get('/claims/new', signedIn, (req, res) => {
  const fields = req.session.kept ?? {};
  delete req.session.kept;
  res.render('claim', { fields });
});
app.post('/claims', signedIn, saveClaim);

app.post('/sign-in', checkPassword, (req, res) => {
  const { returnTo, kept } = req.session;
  req.session.regenerate(() => {
    req.session.user = req.user;
    // 戻すのは、入力したアカウントにだけ
    if (kept?.account === String(req.user.id)) req.session.kept = kept;
    req.session.save(() => res.redirect(returnTo ?? '/'));
  });
});
```

```js
// スクリプトはない。請求は普通のフォームとして送る
```

1. アカウントを隠し入力欄としてフォームに入れます。セッションが切れた後に送られた請求でも、誰のものかが分かります。
2. セッションのない状態で請求が届いたら、ログインへ移す前に、送られた内容を新しいセッションに1日残します。1日は2.2.6が求める20時間より長いため、ログイン画面のまま席を外しても、何も失われません。
3. ログインできたら、同じアカウントのときだけ、残した内容をログイン後のセッションへ移し、フォームへ戻します。
4. フォームには、残した内容を一度だけ入れます。請求は送ったときのまま戻り、すぐに送信できます。

これで修正前の例は直り、2.2.5と2.2.6を満たします。W3Cの達成方法G105に沿った方法で、変えるのはサーバーだけで、スクリプトのない普通のフォームでも使えます。戻るのは読み込み直したフォームなので、利用者はページの先頭から、自分がどこまで進んでいたかを探すことになります。

## 修正後：フォームを離れてよい時間を最初に伝える

```html claim.html
<form id="claim" method="post" action="/claims">
  <h1>保険金の請求</h1>
  <p>セキュリティのため、このフォームを30分間操作しないとログアウトし、送信していない内容は失われます。始める前に、保険証券をお手元にご用意ください。</p>
  <label for="policy">証券番号</label>
  <input id="policy" name="policy" value="{{ fields.policy }}">
  <label for="what">事故の状況</label>
  <textarea id="what" name="what" rows="8">{{ fields.what }}</textarea>
  <button>送信する</button>
</form>
```

```js server.js
// 最後のリクエストから30分でセッションを終える
app.use(session({ secret: process.env.SESSION_SECRET, rolling: true, cookie: { maxAge: 30 * 60 * 1000 } }));

// ログアウト後は、ログインしてから元のページへ戻す。送られた内容は捨てる
function signedIn(req, res, next) {
  if (req.session.user) return next();
  req.session.returnTo = req.method === 'GET' ? req.originalUrl : req.get('Referer');
  res.redirect('/sign-in');
}

app.get('/claims/new', signedIn, (req, res) => res.render('claim', { fields: {} }));
app.post('/claims', signedIn, saveClaim);
app.post('/session/extend', signedIn, (req, res) => res.sendStatus(204));

app.post('/sign-in', checkPassword, (req, res) => {
  const { returnTo } = req.session;
  req.session.regenerate(() => {
    req.session.user = req.user;
    req.session.save(() => res.redirect(returnTo ?? '/'));
  });
});
```

```js
// 入力中は操作しているものとして、1分に1回までセッションを更新する
let renewed = 0;
document.getElementById('claim').addEventListener('input', () => {
  if (Date.now() - renewed < 60 * 1000) return;
  renewed = Date.now();
  fetch('/session/extend', { method: 'POST' });
});
```

1. フォームの最初に、どれだけ離れてよいかと、そのとき失われるものを書きます。30分と、まだ送信していない内容です。
2. 入力している間は、1分に1回までセッションを更新します。これで30分は、案内のとおり、手を止めたときから（1分以内の差で）数えられます。

これで満たせるのは2.2.6だけです。利用者は始める前に制限を知り、必要なものを先にそろえられます。ログインし直すとフォームは空のままなので、2.2.5は満たさず、案内を見逃した人や、一度に終えられない人は、やはり請求の内容を失います。まったく保存してはならない入力欄について2.2.6を満たす方法として、ほかの修正と組み合わせて使います。

## どの方法を選ぶか

ページの上でログインし直す方法と、送られた内容を残す方法は、どちらも2.2.5と2.2.6を満たします。どちらが合うかは、フォームの作りで決まります。フォームをすでにスクリプトで送っているか、サイトがアプリなら、ダイアログのほうが変更は小さく、利用者はページを離れず、どこまで進んでいたかも失いません。スクリプトを使わない普通のHTMLのフォームの場合や、ログインが別のサイトを通るためダイアログに出せない場合は、サーバーで送られた内容を残します。スクリプトは要りませんが、利用者は読み込み直したページに戻ります。案内だけでは足りません。保存してはならない入力欄のために、ほかの二つのどちらかと組み合わせて使います。

## 実装の判断

**セキュリティトークン。** 多くのフレームワークは、クロスサイトリクエストフォージェリを防ぐフォームのトークンをセッションに結び付けています。そのため、セッションが切れた後に送られたフォームは、ここに挙げたコードが動く前に、そのチェックで止まります。Laravelの「419 Page Expired」の画面がこれです。このときも、セッションがない場合と同じように、入力内容を残すか、ページからログインを求めるようにします。ページの上でログインし直すとセッションは新しくなるため、ログインの応答で新しいトークンを返し、それを請求に入れてから送り直します。

**誰のデータか。** 制限があるのは、共用のパソコンや、席を外したままのパソコンのためです。残した内容が次の人に渡ってはいけません。戻すのは入力したアカウントにだけとし、フォームに戻したら削除します。ダイアログはアカウントを入力済みにしてパスワードだけを求めるので、ほかの人がそこから請求を続けることはできません。

**シングルサインオン。** 会社のIDプロバイダーなど、別のサイトを通してログインし直す場合は、ページ自体がそのサイトへ移ってから戻ってくるため、ダイアログではフォームを持ち越せません。サーバーで送られた内容を残すか、ログインをポップアップウィンドウで開き、フォームはその後ろに残します。

**タブの中のコピー。** サーバーを変えられない場合は、入力に合わせてスクリプトでフォームを `sessionStorage` にコピーし、ログイン後に同じタブでフォームが読み込まれたときに戻す方法もあります。コピーはタブがある間だけ残り、タブを閉じれば消えます。アカウントも一緒に保存し、同じアカウントのときだけフォームに戻します。

**ファイル。** ページからファイルの入力欄の値を設定することはできず、スクリプトが `files` で戻せるのも、自分で保存しておいたファイルだけです。そのため、請求のために選んだ写真は、読み込み直すと消えてしまいます。添付ファイルは選ばれた時点でアップロードして請求と一緒に一覧にするか、ページの上でログインし直す方法を使います。その方法なら、選んだファイルは入力欄に残ります。ただし、ファイルを送れない `URLSearchParams` ではなく、`FormData` のまま送信します。

**長い休みのための下書き。** 入力に合わせて下書きをアカウントに保存し、20時間より長く残せば、さらに確実です。タブを閉じても、ブラウザーが落ちても、別の端末に替えても残り、20時間より長く残すデータについては、2.2.6は案内を求めていません。ただし、セッションが切れた後に入力した分は、ログインし直すまで保存できないため、ほかの修正のどちらかと組み合わせます。W3Cは、こうしたデータを残す前に、プライバシーに関する規制が同意を求める場合があると注意しています。健康や支払いの情報を含むフォームでは、プライバシーと法務の担当者に相談してください。

**残してはならないもの。** PCI DSSは、カード番号とセキュリティコードの保存を厳しく制限しているため、どちらもこの方法では残しません。ログインし直した後はその欄を空のまま表示して、入力し直す欄を示します。その欄の制限は、案内のようにフォームの最初に伝えます。

**修正にならないもの。** 30分を60分にするなど、セッションを長くしても、それより長く離れた人の請求は失われます。終了の前に警告し、ログインを続けられるダイアログは2.2.1が求めるもので、別のガイド[警告も延長もないセッションタイムアウト](/ja/learn/time-limits)で扱っています。警告を見逃した人はフォームを失うため、それだけでは2.2.5を満たしません。

## 修正の確認

1. サーバーのセッション設定とログインのコードを読み、セッションが切れた後に送られたフォームがどうなるかを調べる。残すかどうか、どこに、どれだけの間残し、誰に戻すか。
2. セッションを2分に縮めたテスト用のビルドで、すべての入力欄を埋め、制限を過ぎるまで待ち、フォームを送信してからログインし直す。入力した内容がすべて残っているか、フォームが送信されている。
3. もう一度同じことをし、今度は別のアカウントでログインする。最初のアカウントの請求は表示されない。
4. キーボードだけで、またスクリーンリーダーでも、手順2を繰り返す。ログインの手順にたどり着いて済ませることができ、その後、ページを探し回らなくても請求の内容にたどり着ける。
5. 待つだけで入力が失われる可能性が残る場所では、作業の最初に、どれだけ離れてよいかが文章で書かれていることを確かめる。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。セッションの設定、ログインの流れ、フォームを変更した際は、再度確認します。

## 制限

このガイドで扱うのは、セッションが切れたときに入力した内容が失われることと、それまでの時間を伝えることです。制限の前に警告し、延長できるようにするのはレベルAで、別のガイド[警告も延長もないセッションタイムアウト](/ja/learn/time-limits)で扱っています。時間制限そのものがある作業は2.2.3「タイミング非依存」の対象で、別のガイド[確保した座席が時間切れで解放されてしまう購入手続きのタイマー](/ja/learn/checkout-timers)で扱っています。2.2.5と2.2.6はAAAで、W3Cはサイト全体にAAAを求めることを勧めていません。それでも、ログインが必要な長いフォームなら、入力した内容を残すだけでどちらも満たせます。入力内容を残して安全かどうか、どれだけの間残すかは、サービスを運営する人が、プライバシーの担当者と一緒に決めることです。

## スクリーンリーダーでの動き（EaseWebが実機で確認）

`<dialog>`: showModal() で開くダイアログです。フォーカスがダイアログの中へ移ります。

| スクリーンリーダー | ブラウザーとOS | 開く |
| --- | --- | --- |
| NVDA | Chrome, Windows 11 | 未確認 |
| JAWS | Chrome, Windows 11 | 未確認 |
| VoiceOver | Safari, iOS | 未確認 |
| TalkBack | Chrome, Android | 未確認 |

https://easeweb.dev/ja/learn/screen-reader-support

---

このガイドをコーディングエージェントで使う: MCP https://easeweb.dev/mcp（設定は https://easeweb.dev/ja/services/developers）
