WCAG 3.3.7 Redundant Entry Compliance Checklist

WCAG 3.3.7 (Redundant Entry) fails a multi-step form the moment it asks a user to re-type information they already supplied in the same session, so this checklist pins down exactly where a JavaScript-driven flow leaks that data and how to auto-populate or persist it while still routing every field through the Constraint Validation API baseline.

When to Use This Redundant-Entry Auditing Approach

Reach for this checklist when a form spans more than one screen, page navigation, or logical step and the same value (email, address, name) is legitimately needed twice. It is distinct from field-level error work: if you are auditing how a single input announces a mistake, use the WCAG 3.3.1 Error Identification Checklist instead, and if you are auditing whether the message tells the user how to fix it, use the WCAG 3.3.3 Error Suggestion Patterns.

Use this approach when:

  • A value entered in step 1 is re-requested in step 2+ of the same process.
  • A “same as billing address” or “confirm email” situation could be satisfied by recall rather than re-entry.
  • Session data survives navigation and you can pre-fill or offer a selectable prior value.

Skip it for the two exceptions the success criterion carves out: re-entry that is essential (a password confirmation, a security re-authentication) or where the previously entered information is no longer valid.

Redundant Entry: Re-Type vs Recall Two approaches to a value needed twice in a multi-step form: forcing re-entry versus auto-populating from session state. RE-TYPE (FAILS 3.3.7) - step 2 shows an empty field - user re-keys the same email - new typos, new friction best when: value is essential RECALL (PASSES 3.3.7) - value persisted in session - field auto-populated on load - user confirms, does not retype best when: value already known
Redundant Entry compliance means the second request recalls the value instead of demanding a re-key.

sessionStorage-Backed Auto-Population With checkValidity()

The minimal fix persists each validated value to sessionStorage, then rehydrates any later field that asks for the same data. Validation stays on the native novalidate + Constraint Validation baseline: nothing is submitted until a single reportValidity() call passes.

// Keys shared across every step of the flow.
type CarriedField = "email" | "fullName" | "postalCode";
const STORE_KEY = "flow:carried";

function readCarried(): Partial<Record<CarriedField, string>> {
  try {
    return JSON.parse(sessionStorage.getItem(STORE_KEY) ?? "{}");
  } catch {
    return {};
  }
}

function writeCarried(patch: Partial<Record<CarriedField, string>>): void {
  const next = { ...readCarried(), ...patch };
  sessionStorage.setItem(STORE_KEY, JSON.stringify(next));
}

// Rehydrate any field whose name matches a value we already collected.
// The user sees the prior answer and confirms rather than re-typing it.
function autoPopulate(form: HTMLFormElement): void {
  const carried = readCarried();
  for (const [name, value] of Object.entries(carried)) {
    const field = form.elements.namedItem(name);
    if (field instanceof HTMLInputElement && field.value === "") {
      field.value = value;
    }
  }
}

// Single manual validity gate — the site-wide baseline.
function wireStep(form: HTMLFormElement): void {
  autoPopulate(form); // recall BEFORE the user interacts

  form.addEventListener("submit", (event) => {
    // form is <form novalidate>, so nothing auto-blocks; we decide.
    if (!form.checkValidity()) {
      event.preventDefault();
      form.reportValidity(); // one call surfaces native + custom messages
      return;
    }
    // Persist validated values so the NEXT step can recall them.
    const patch: Partial<Record<CarriedField, string>> = {};
    for (const name of ["email", "fullName", "postalCode"] as CarriedField[]) {
      const field = form.elements.namedItem(name);
      if (field instanceof HTMLInputElement && field.value) {
        patch[name] = field.value;
      }
    }
    writeCarried(patch);
  });
}

document
  .querySelectorAll<HTMLFormElement>("form[data-flow-step]")
  .forEach(wireStep);
<!-- Step 2 re-uses the email from step 1 instead of re-asking blank. -->
<form data-flow-step="2" novalidate>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required autocomplete="email" />
  <button type="submit">Continue</button>
</form>

Storage & Recall: Parameter / Option Reference

Option Type Default Purpose
store "session" | "memory" | "url" "session" Where carried values live between steps; sessionStorage clears on tab close.
keys CarriedField[] [] Whitelist of field names eligible for recall; keeps unrelated inputs untouched.
overwriteFilled boolean false If false, only populate empty fields so a user edit is never clobbered.
essential CarriedField[] [] Fields exempt from recall (password confirm, re-auth) per the 3.3.7 exception.
expireOnInvalid boolean true Drop a carried value once server checks mark it stale, so 3.3.7 never recalls bad data.
autocomplete string field-specific Native token (email, postal-code) that lets the browser recall too.

Verification Steps

import { test, expect } from "@playwright/test";

test("email carried from step 1 into step 2 (WCAG 3.3.7)", async ({ page }) => {
  await page.goto("/checkout/step-1");
  await page.getByLabel("Email").fill("dev@example.com");
  await page.getByRole("button", { name: "Continue" }).click();

  // Step 2 must recall, not re-ask.
  await expect(page.getByLabel("Email")).toHaveValue("dev@example.com");
});

Edge Cases & Failure Modes

Stale recall after a failed server check. A value can pass client validation, be persisted, then be rejected by asynchronous server checks (email already taken). If you keep recalling it, you re-present invalid data. Fix: honour expireOnInvalid and delete the key the moment the server marks it stale, then let the field render empty and required again.

Auto-population clobbering a deliberate edit. Blindly writing carried values on every navigation overwrites a correction the user just made on a later step. Fix: only populate when field.value === "" and keep overwriteFilled false, so recall assists but never fights the user. Pair this with clear accessible error messaging if the recalled value still fails.

Cross-field confirmation treated as redundant. A “confirm email” or cross-field validation pair looks like redundant entry but is essential when it exists to catch typos — silently pre-filling it defeats the purpose. Fix: list confirmation inputs in essential so they are exempt from recall while still validated.

Offering Selectable Prior Values Instead of a Pre-Fill

The success criterion is satisfied two ways: the value is auto-populated, or it is “available for the user to select.” That second clause matters whenever a single answer is genuinely ambiguous — a user who has entered three shipping addresses in one session should be offered them, not silently locked into the most recent one. A native select or a datalist-backed input turns recall into a choice while keeping the same Constraint Validation gate, so the field still reports valueMissing if the user picks nothing.

// Offer every prior value for a repeated field as a selectable option,
// rather than forcing the single most-recent value into place. This is the
// "available to select" branch of 3.3.7 and is the correct pattern whenever
// more than one legitimate prior answer exists in the session.
interface RecallHistory {
  [field: string]: string[]; // insertion-ordered, de-duplicated prior values
}

const HISTORY_KEY = "flow:history";

function pushHistory(name: string, value: string): void {
  const history: RecallHistory = JSON.parse(
    sessionStorage.getItem(HISTORY_KEY) ?? "{}",
  );
  const prior = history[name] ?? [];
  // Move an existing value to the front instead of duplicating it.
  history[name] = [value, ...prior.filter((v) => v !== value)].slice(0, 5);
  sessionStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}

// Bind a <datalist> so the browser surfaces prior answers as suggestions.
// The input stays `required`, so an empty pick still trips checkValidity().
function offerSuggestions(input: HTMLInputElement): void {
  const history: RecallHistory = JSON.parse(
    sessionStorage.getItem(HISTORY_KEY) ?? "{}",
  );
  const values = history[input.name] ?? [];
  if (values.length === 0) return;

  const list = document.createElement("datalist");
  list.id = `${input.name}-recall`;
  for (const value of values) {
    const option = document.createElement("option");
    option.value = value;
    list.append(option);
  }
  input.after(list);
  input.setAttribute("list", list.id);
}

Prefer this over a hard pre-fill when the field is address-shaped, name-shaped, or otherwise plural in real life. Reserve the direct field.value = value path for genuinely single-valued data — a session email, a logged-in user’s name — where offering a one-item menu would be pointless friction.

Persisting Recall Without Storing Sensitive Data

sessionStorage is same-origin, cleared on tab close, and never sent to the server, which makes it a reasonable default — but it is still readable by any script running on the page, so an XSS foothold can exfiltrate whatever you cache. That reframes 3.3.7 as a data-minimisation exercise: recall only what re-entry would genuinely burden, and never persist high-sensitivity values (full card numbers, CVCs, government IDs, one-time codes) merely to avoid a re-type. Those fields belong in the essential list precisely because re-authentication is the point.

For values that should persist but should not be broadcast, prefer the url store only for non-secret identifiers, since query strings land in history, referrer headers, and server logs. When a carried value is sensitive-but-recallable — a partial address, say — scope its lifetime tightly and clear it on completion.

// Clear carried + historical values the moment the flow completes,
// so a shared or public machine does not leak recalled data to the next user.
function clearFlowState(): void {
  sessionStorage.removeItem(STORE_KEY);
  sessionStorage.removeItem(HISTORY_KEY);
}

// Call on the terminal step's successful submit, and defensively on unload
// for kiosk-style deployments where tab-close cleanup is not guaranteed.
window.addEventListener("pagehide", (event) => {
  if (!event.persisted && document.body.dataset.flowComplete === "true") {
    clearFlowState();
  }
});

Treat the storage key itself as a whitelist boundary: iterate only the keys array when writing, never form.elements wholesale, so a hidden token or an unexpectedly-named input can never be swept into persistence and recalled onto a later screen.

Frequently Asked Questions

Does WCAG 3.3.7 require me to auto-fill fields, or just not re-ask?

Either satisfies it. The criterion says previously entered information must be auto-populated or available for the user to select. A dropdown of prior addresses passes just as well as a pre-filled input, as long as the user is not forced to re-key from memory.

Is a "confirm password" field a 3.3.7 violation?

No. Re-entry is explicitly allowed when it is essential, and a confirmation field exists to guard against typos, which is an essential use. List it under essential so your recall logic leaves it blank while still validating both values match.

Should I use sessionStorage or a schema like Zod to carry values?

They solve different jobs. sessionStorage is transport between steps; Zod schema validation shapes and validates the carried object before you trust it. Parse the stored blob through your schema on read so a tampered or outdated value never rehydrates a field.

Does the browser's own autofill count toward 3.3.7 compliance?

Not on its own. 3.3.7 concerns information entered within the same process, and the browser's autofill draws on a separate, cross-site profile that may be absent, stale, or disabled. Correct autocomplete tokens are a genuine help and satisfy related input-purpose criteria, but you still owe an in-session recall path so the criterion holds when autofill is unavailable.

Does auto-populating a field skip the validation I need to run on it?

Setting field.value in script does not fire input or change, so any live listeners stay silent until interaction. That is harmless because the site-wide gate is a single checkValidity() on submit, which re-reads the current value regardless of how it arrived. If you drive per-field UI from events, dispatch a synthetic new Event("input", { bubbles: true }) after the recall so the on-screen state matches the value you injected.

← Back to WCAG 2.2 Form Compliance Checklists