# Session timeouts that bring you back to an empty form

Source: https://easeweb.dev/learn/session-expiry
Topics: Time limits and motion > Sessions warn people before inactivity loses their data (WCAG 2.2.6); Time limits and motion > Sessions keep what people typed when they expire (WCAG 2.2.5)
The fix: Either send the form from the page and, when the session has ended, ask for the password in a dialog over it and send the same form again, or keep what arrives after the session has ended on the server for a day and put it back in the form once the same account signs in. Saying at the start how long the form can be left meets 2.2.6, but the form is still lost.
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

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

An insurer's claim form asks for a policy number and what happened, in the customer's own words. A leak through the kitchen ceiling takes a while to explain: people look up dates, find a plumber's receipt, or stop halfway for a break. For security, the insurer signs people out 30 minutes after their last request to the server. Typing sends nothing, so the clock runs while someone writes, and nothing on the form says there is a limit at all. Forty minutes later, the customer presses Send claim. The server finds no session, drops what was sent, and redirects to the sign-in page: "Your session has expired. Please sign in again." After signing in, they are back on the claim form, and it is empty.

This is what common frameworks do by default. Devise, a sign-in library for Rails, and Laravel both send people back to the page they came from after signing in again, but they remember only the page's address, not what was posted to it, and Django's sign-in redirect keeps only a `next` path. In Laravel, the form's security token also ends with the session, so the same press often shows a "419 Page Expired" screen instead.

## Why it fails

Success Criterion 2.2.5 Re-authenticating asks that when a signed-in session expires, people can continue what they were doing after signing in again, without losing data. The limit itself is allowed: 2.2.5 doesn't ask for a longer session, only that nothing typed is lost when it ends. W3C's failure F12 describes this form exactly: the session ends, the person signs in again, and the form is not filled with what they entered, so someone who needs more time than the limit can never finish it.

Success Criterion 2.2.6 Timeouts asks that people are told how long they can be inactive before they lose data, unless the data is kept for more than 20 hours. W3C asks for the notice once, at the start of the task, so people can decide whether to gather their documents first or take a break. The form says nothing and keeps nothing, so a lunch break costs everything typed. The browser plays no part in either: the page sends an ordinary form, and the server chooses to throw the fields away when it finds the session gone.

## Who is affected

A description that takes one person ten minutes can take another an hour, and the people who need longer are the ones who lose the most: they write the slow part twice, or give up on the claim. On a phone, switching to another app to find a photo or a policy document sends nothing to the server either, so that time counts against the limit too.

## What automation and AI miss

The limit is a server setting, and the redirect happens only to a form sent after it, so a scan sees a form that works. A scan doesn't sign in, wait half an hour and send the form, and nothing in the page says whether the server would keep what was sent, so the audit has no rule for this failure: only someone who waits it out finds it. Asked to put a form behind a sign-in, an AI assistant may write the framework's own redirect, which is the Before below, and may add a session limit without a word about the form.

## Before: a sign-in that drops what was sent

```html claim.html
<form id="claim" method="post" action="/claims">
  <h1>Make a claim</h1>
  <label for="policy">Policy number</label>
  <input id="policy" name="policy" value="{{ fields.policy }}">
  <label for="what">What happened?</label>
  <textarea id="what" name="what" rows="8">{{ fields.what }}</textarea>
  <button>Send claim</button>
</form>
```

```js server.js
// A session ends 30 minutes after the last request.
app.use(session({ secret: process.env.SESSION_SECRET, rolling: true, cookie: { maxAge: 30 * 60 * 1000 } }));

// Signed out: sign in, then back to the page they came from. What was sent is dropped.
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
// No script: the claim is sent as an ordinary form.
```

- Nothing on the form says that people are signed out after 30 minutes, or that what they typed is then lost.
- Typing sends nothing, so 30 minutes of writing counts as 30 minutes away.
- A claim sent after that is dropped: only the form's address is kept for after sign-in.
- After signing in, the form comes back empty.

## After: sign in again on the page, then send the same claim

```html claim.html
<form id="claim" method="post" action="/claims">
  <h1>Make a claim</h1>
  <label for="policy">Policy number</label>
  <input id="policy" name="policy" value="{{ fields.policy }}">
  <label for="what">What happened?</label>
  <textarea id="what" name="what" rows="8">{{ fields.what }}</textarea>
  <button>Send claim</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">Sign in again to send your claim</h2>
    <p>Your claim is still here. Enter your password to send it.</p>
    <input type="hidden" name="username" value="{{ user.email }}" autocomplete="username">
    <label for="password">Password</label>
    <input id="password" name="password" type="password" autocomplete="current-password">
    <button>Sign in and send</button>
  </form>
</dialog>
```

```js server.js
// A session ends 30 minutes after the last request.
app.use(session({ secret: process.env.SESSION_SECRET, rolling: true, cookie: { maxAge: 30 * 60 * 1000 } }));

// Signed out: a page is sent to sign in; a claim is answered 401, and the page asks.
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));

// Send the claim from the page, so it stays in the form whatever the answer.
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();
});

// Signed in again: close the dialog and send the same claim.
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. Send the claim from the page with `fetch`, so the form stays as it is whatever the server answers.
2. When the session has ended, answer the claim with 401 instead of a redirect, so the page knows to ask.
3. Open a sign-in dialog over the form with `showModal()`. Focus moves into it, the account is already filled in, so only the password is asked for, and a password manager can fill that in.
4. Once signed in, close the dialog and send the same claim again.

This fixes the Before and passes 2.2.5 and 2.2.6: the claim never leaves the page, so no time away loses it. Only closing the tab does, and W3C doesn't count that as a timeout. It needs a script, and a sign-in the page can make itself; a site that signs people in through another site needs the next fix, or the notes on single sign-on below.

## After: keep what was sent while people sign in again

```html claim.html
<form id="claim" method="post" action="/claims">
  <h1>Make a claim</h1>
  <input type="hidden" name="account" value="{{ user.id }}">
  <label for="policy">Policy number</label>
  <input id="policy" name="policy" value="{{ fields.policy }}">
  <label for="what">What happened?</label>
  <textarea id="what" name="what" rows="8">{{ fields.what }}</textarea>
  <button>Send claim</button>
</form>
```

```js server.js
// A session ends 30 minutes after the last request.
app.use(session({ secret: process.env.SESSION_SECRET, rolling: true, cookie: { maxAge: 30 * 60 * 1000 } }));

// Signed out: keep what was sent, for a day, while they sign in again.
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');
}

// The form, filled with what was kept, if there is any.
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;
    // Only the account that typed it gets it back.
    if (kept?.account === String(req.user.id)) req.session.kept = kept;
    req.session.save(() => res.redirect(returnTo ?? '/'));
  });
});
```

```js
// No script: the claim is sent as an ordinary form.
```

1. Put the account in the form as a hidden field, so a claim sent after the session has ended still says whose it is.
2. When a claim arrives with no session, keep what was sent in the new session, for a day, before sending people to sign in. A day is longer than the 20 hours 2.2.6 asks for, so time spent away with the sign-in page open loses nothing either.
3. After signing in, carry it into the signed-in session only if the same account has signed in, and send people back to the form.
4. Fill the form from it, once. The claim is back as it was sent, ready to send.

This fixes the Before and passes 2.2.5 and 2.2.6. It follows W3C's technique G105, is a change on the server only, and works for an ordinary form with no script. People land on a reloaded form, so they start again at the top of the page and have to find their place.

## After: say how long the form can be left, at the start

```html claim.html
<form id="claim" method="post" action="/claims">
  <h1>Make a claim</h1>
  <p>For your security, we sign you out if you leave this form for 30 minutes, and anything you haven't sent is lost. Have your policy documents to hand before you start.</p>
  <label for="policy">Policy number</label>
  <input id="policy" name="policy" value="{{ fields.policy }}">
  <label for="what">What happened?</label>
  <textarea id="what" name="what" rows="8">{{ fields.what }}</textarea>
  <button>Send claim</button>
</form>
```

```js server.js
// A session ends 30 minutes after the last request.
app.use(session({ secret: process.env.SESSION_SECRET, rolling: true, cookie: { maxAge: 30 * 60 * 1000 } }));

// Signed out: sign in, then back to the page they came from. What was sent is dropped.
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
// Typing counts as being there: renew the session at most once a minute while people type.
let renewed = 0;
document.getElementById('claim').addEventListener('input', () => {
  if (Date.now() - renewed < 60 * 1000) return;
  renewed = Date.now();
  fetch('/session/extend', { method: 'POST' });
});
```

1. Say at the start of the form how long it can be left, and what is lost then: 30 minutes, and anything not yet sent.
2. Renew the session while people type, at most once a minute, so the 30 minutes count from when they stop, to within a minute, as the notice says.

This passes 2.2.6 and nothing more. People know the limit before they start, and can gather what they need first. The form is still emptied after signing in again, so 2.2.5 still fails, and anyone who misses the notice, or can't finish in one sitting, still loses the claim. It is the way to meet 2.2.6 for fields that must not be kept at all, beside one of the other fixes for the rest.

## Which option to use

Signing in on the page and keeping what was sent both pass 2.2.5 and 2.2.6, and which one fits depends on how the form is built. If the form is already sent by script, or the site is an app, the dialog is the smaller change, and people never leave the page or lose their place in it. If the form is an ordinary HTML form, or signing in goes through another site that a dialog can't show, keep what was sent on the server: it needs no script, but people come back to a reloaded page. The notice alone is not enough: use it for fields you must not keep, beside one of the other two.

## Implementation decisions

**Security tokens.** Many frameworks tie a form's security token (against cross-site request forgery) to the session, so a form sent after the session has ended fails that check before any of this code runs: in Laravel, that is the "419 Page Expired" screen. Handle that failure the same way as a missing session, by keeping the fields or asking the page to sign in. After signing in on the page, the session is new, so have the sign-in answer with its new token and put that into the claim before sending it again.

**Whose data it is.** The limit is there for shared and unattended computers, so what is kept must not reach the next person. Give it back only to the account that typed it, and delete it once it is back in the form. The dialog fills in the account and asks only for its password, so nobody else can carry on with the claim from it.

**Single sign-on.** When signing in again goes through another site, such as a company's identity provider, the page itself leaves for that site and comes back, and a dialog can't hold the form across the trip. Keep what was sent on the server, or have people sign in through a pop-up window while the form stays behind it.

**A copy in the tab.** Where the server can't be changed, a script can copy the form into `sessionStorage` as people type, and put it back when the form loads again in the same tab after signing in. The copy lasts as long as the tab, and is gone once it is closed. Store the account with it, and fill the form only for the same account.

**Files.** A page can't set a file field's value, and a script can put back through `files` only a file it has kept itself, so a photo chosen for the claim is gone after a reload. Upload attachments as soon as they are chosen, and list them with the claim, or sign in on the page, where the chosen file stays in its field; send the form then as `FormData` itself, since `URLSearchParams` can't carry a file.

**A draft for longer breaks.** Saving a draft to the account as people type, kept for more than 20 hours, goes further: it survives a closed tab, a crash or a change of device, and 2.2.6 doesn't ask for a notice for data kept that long. Pair it with one of the fixes, because anything typed after the session has ended can't be saved until they sign in again. W3C notes that privacy rules may require consent before data like this is kept: ask your privacy and legal advisers when a form holds health or payment details.

**What not to keep.** PCI DSS strictly limits storing card numbers and security codes, so keep neither this way. Leave those fields empty after signing in again, say which ones need filling, and give the limit at the start for them, as the notice does.

**Not a fix.** A longer session, such as 60 minutes instead of 30, still loses the claim of whoever is away longer. A dialog that warns before the end and lets people stay signed in is what 2.2.1 asks for, and has its own guide, [session timeouts with no warning or way to extend](/learn/time-limits); someone who misses it still loses the form, so it doesn't pass 2.2.5 on its own.

## Verify the fix

1. Read the server's session settings and its sign-in code, and find out what happens to a form sent after the session has ended: whether it is kept, where, for how long, and who gets it back.
2. In a test build with the session shortened to two minutes, fill in every field, wait past the limit, send the form and sign in again: everything typed is still there, or the form has gone through.
3. Do it again, but sign in as a different account: the first account's claim is not shown.
4. Using only the keyboard, and again with a screen reader, repeat step 2: you can reach and complete the sign-in, and afterwards find your claim without searching the page for it.
5. Wherever something typed can still be lost by waiting, check that the start of the task says how long it can be left, in text.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the session settings, the sign-in flow or the form change.

## Limits

This covers losing what was typed when a session ends, and saying how long that takes. Warning people before a limit ends and letting them extend it is level A, with its own guide: [session timeouts with no warning or way to extend](/learn/time-limits). A task with any time limit at all is 2.2.3 No Timing, with its own guide: [checkout timers that release held seats](/learn/checkout-timers). 2.2.5 and 2.2.6 are AAA, and W3C doesn't recommend requiring AAA for whole sites, but for a long form behind a sign-in, both are met by keeping what was typed. Whether that is safe to keep, and for how long, is for the people who run the service to decide, with their privacy advisers.

## What screen readers do (tested by hand by EaseWeb)

`<dialog>`: A dialog opened with showModal(): focus moves into it.

| Screen reader | Browser and system | Open it |
| --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested |
| NVDA | Chrome, Windows 11 | Not tested |
| JAWS | Edge, Windows 11 | Not tested |
| NVDA | Firefox, Windows 11 | Not tested |
| NVDA | Edge, Windows 11 | Not tested |
| JAWS | Firefox, Windows 11 | Not tested |
| VoiceOver | Safari, macOS | Not tested |
| VoiceOver | Safari, iOS | Not tested |
| TalkBack | Chrome, Android | Not tested |

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

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
