Age Verification and Date of Birth Validation
How do you check that a user is at least 18 without being off by a day on their birthday, without crashing on 29 February, and without making them scroll a calendar back thirty years? This recipe collects the date of birth in three labelled fields, validates that they form a real date, computes age by comparing year, month and day rather than dividing milliseconds, handles leap-day birthdays by an explicit rule, and reports every failure through setCustomValidity() so the fields fail through the same Constraint Validation API path as the rest of the form.
When to Use This Date of Birth Pattern
Use it for any form that asks for a birth date — sign-ups with a minimum age, insurance quotes, age-restricted purchases, identity checks. The three-field layout specifically suits dates that people know rather than look up:
- Dates of birth are typed from memory; a calendar picker that starts at today forces dozens of clicks.
- Minimum-age rules need exact birthday arithmetic, which is easy to get wrong with timestamp maths.
- Accessibility matters — three numeric text fields are predictable for screen reader and voice users across every browser.
Use a native type="date" input for dates people pick relative to now, such as appointments, as described in validating date input min, max and format. And remember that a typed birth date is a self-declaration: it deters casual under-age sign-ups but proves nothing. Regulated age checks need document or third-party verification on the server.
Minimal Working Date of Birth Validator
export interface DobParts { day: string; month: string; year: string }
export interface DobResult { error: string; field?: "day" | "month" | "year"; iso?: string }
const isLeap = (y: number) => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
const daysIn = (y: number, m: number) => [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
/** Age in whole years on a given calendar date — exact on birthdays. */
export function ageOn(birth: { y: number; m: number; d: number }, on: { y: number; m: number; d: number }): number {
let age = on.y - birth.y;
// Leap-day birthdays: treat 29 Feb as 28 Feb in non-leap years (a policy choice — see below).
const bd = birth.m === 2 && birth.d === 29 && !isLeap(on.y) ? 28 : birth.d;
if (on.m < birth.m || (on.m === birth.m && on.d < bd)) age--;
return age;
}
export function checkDob(p: DobParts, minAge: number, now = new Date()): DobResult {
const [d, m, y] = [p.day, p.month, p.year].map((s) => s.trim());
if (!d && !m && !y) return { error: "Enter your date of birth.", field: "day" };
if (!d) return { error: "Date of birth must include a day.", field: "day" };
if (!m) return { error: "Date of birth must include a month.", field: "month" };
if (!y) return { error: "Date of birth must include a year.", field: "year" };
if (![d, m, y].every((s) => /^\d+$/.test(s))) return { error: "Date of birth must only use numbers.", field: "day" };
const [day, month, year] = [Number(d), Number(m), Number(y)];
if (y.length !== 4) return { error: "Year must include 4 numbers, for example 1990.", field: "year" };
if (month < 1 || month > 12) return { error: "Month must be between 1 and 12.", field: "month" };
if (day < 1 || day > daysIn(year, month)) {
return { error: `Day must be between 1 and ${daysIn(year, month)} for that month.`, field: "day" };
}
const today = { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() };
const iso = `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
const todayIso = `${today.y}-${String(today.m).padStart(2, "0")}-${String(today.d).padStart(2, "0")}`;
if (iso > todayIso) return { error: "Date of birth must be in the past.", field: "year" };
if (year < today.y - 120) return { error: "Check the year — it looks too long ago.", field: "year" };
if (ageOn({ y: year, m: month, d: day }, today) < minAge) {
return { error: `You must be ${minAge} or older to open an account.`, field: "year" };
}
return { error: "", iso };
}
// Wiring: the error goes on the specific part that is wrong; the fieldset carries the message.
const form = document.querySelector<HTMLFormElement>("#signup")!;
const parts = {
day: form.querySelector<HTMLInputElement>("#dob-day")!,
month: form.querySelector<HTMLInputElement>("#dob-month")!,
year: form.querySelector<HTMLInputElement>("#dob-year")!,
};
const errorEl = form.querySelector<HTMLElement>("#dob-err")!;
const hidden = form.querySelector<HTMLInputElement>("#dob")!; // name="dob", type="hidden", ISO value
function validateDob(): boolean {
const r = checkDob({ day: parts.day.value, month: parts.month.value, year: parts.year.value }, 18);
for (const [key, el] of Object.entries(parts)) {
el.setCustomValidity(r.field === key ? r.error : "");
el.toggleAttribute("aria-invalid", r.field === key);
}
errorEl.textContent = r.error;
errorEl.hidden = !r.error;
hidden.value = r.iso ?? "";
return !r.error;
}
form.addEventListener("submit", (event) => {
validateDob();
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity(); // focuses the specific part that is wrong
}
});
Placing the custom validity on the specific part — the month field for “Month must be between 1 and 12” — means reportValidity() focuses exactly the input the user needs to change. The fieldset-level message repeats it visibly for sighted users, and the hidden ISO field gives the server one unambiguous value.
Date of Birth Validator Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
minAge |
number |
18 |
Minimum whole years on today’s date |
now |
Date |
new Date() |
Injectable for tests and for server-side checks |
| Leap-day policy | rule in ageOn |
28 Feb in non-leap years | Some jurisdictions use 1 March instead |
| Maximum age | number |
120 |
Catches 1026 typed for 2026, not a legal limit |
| Error placement | field in result |
first failing part | reportValidity() focuses that part |
| Submitted value | hidden ISO field | YYYY-MM-DD |
Unambiguous for the server |
autocomplete |
tokens | bday-day, bday-month, bday-year |
Browser fill for returning users |
Verification Steps
import { describe, it, expect } from "vitest";
import { checkDob, ageOn } from "./dob";
const NOW = new Date(2026, 8, 18); // 18 September 2026, local time
describe("date of birth", () => {
it("accepts someone turning 18 today", () => {
expect(checkDob({ day: "18", month: "9", year: "2008" }, 18, NOW).error).toBe("");
});
it("rejects someone turning 18 tomorrow", () => {
expect(checkDob({ day: "19", month: "9", year: "2008" }, 18, NOW).error).toMatch(/18 or older/);
});
it("knows April has 30 days", () => {
expect(checkDob({ day: "31", month: "4", year: "1990" }, 18, NOW)).toMatchObject({ field: "day" });
});
it("applies the 28 February policy for leap-day birthdays", () => {
expect(ageOn({ y: 2008, m: 2, d: 29 }, { y: 2026, m: 2, d: 28 })).toBe(18);
expect(ageOn({ y: 2008, m: 2, d: 29 }, { y: 2026, m: 2, d: 27 })).toBe(17);
});
});
Edge Cases and Failure Modes
Age from milliseconds. Math.floor((now - dob) / (365.25 * 24 * 3600 * 1000)) drifts by up to a day around birthdays because years are not 365.25 days long in any individual case, and it is affected by daylight saving when both dates are local. Always compare year, month and day.
Whose “today”? The user’s local date and the server’s UTC date can differ for several hours around midnight. A user who turns 18 today in Auckland is still 17 according to a server in UTC. Decide which date is authoritative — usually the jurisdiction’s local date — and compute it on the server in that zone; the client check is a courtesy.
Two-digit years. Users type 90 for 1990. Rejecting with “Year must include 4 numbers” is clearer than guessing a century, which would put a 26 either in 1926 or 2026.
Month names typed into the month field. Some users type “Sep”. Either accept month names and abbreviations in the user’s language (Intl.DateTimeFormat can generate the list) or state “a number from 1 to 12” in the hint.
Choosing the Leap-Day Policy
Someone born on 29 February has no birthday in three years out of four, so every age rule needs a decision about which day counts. The two common conventions are 28 February and 1 March, and legal systems differ: some treat the person as reaching the age on 28 February, others on 1 March. The recipe uses 28 February because it never delays access, but the important thing is that the choice is explicit, documented next to the code, tested, and identical on client and server. A policy that exists only as an accident of how Date rolls over (JavaScript turns new Date(2026, 1, 29) into 1 March) is a policy nobody chose.
Keeping the Age Check Honest and Respectful
A minimum-age check asks users to disclose personal data, so ask only when a rule requires it and say why in the legend or hint: “We need your date of birth to confirm you are 18 or over.” Do not reveal the threshold in a way that coaches people to lie on the second attempt — the message above states the rule, which is fair, but avoid clearing the fields or offering an immediate retry that invites a quick edit of the year. If the service is legally age-restricted, log failed attempts server-side and consider a cool-down; if it is merely age-appropriate, a clear message and a link to suitable alternatives is kinder. Either way, never store more than you need: if you only need to know that the user is over 18, you can store the verification result and discard the exact date after the check, which is the data-minimisation approach many privacy regimes expect.
Frequently Asked Questions
How do I calculate age correctly in JavaScript?
Subtract the birth year from the current year, then subtract one if this year's birthday has not happened yet, comparing month and then day. Avoid dividing millisecond differences by 365.25 days, which is wrong around birthdays.
Should date of birth use a date picker?
Usually not. Three numeric fields for day, month and year are faster to fill from memory and behave consistently with assistive technology. Pickers suit dates chosen relative to today, such as appointments.
How should age be handled for people born on 29 February?
Choose a policy — birthday on 28 February or on 1 March in non-leap years — document it, test it, and use the same rule on client and server. Some jurisdictions prescribe one or the other.
Is a typed date of birth enough for legal age verification?
No. It is a self-declaration that deters casual misuse. Regulated age checks need identity documents or a third-party verification service, performed on the server.
Related Guides
- Date and Time Validation — calendar dates, instants and time zones.
- Validating Date Input Min, Max and Format — the native date input alternative.
- WCAG 3.3.3 Error Suggestion Patterns — wording the part-specific messages.
- Validating Radio Groups and Fieldsets — grouping related inputs for validation and announcement.