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.

Split fields versus a date picker for dates of birth Two columns comparing three day-month-year text fields with a calendar date picker for entering a date of birth. Day / month / year fields • three numeric inputs in a fieldset ✓ fast to type from memory ✓ consistent across browsers and assistive tech ✓ autocomplete bday-day / bday-month / bday-year ✗ needs your own real-date check Calendar picker • native type=date or custom widget ✓ prevents impossible dates ✗ starts at today: many clicks back to 1985 ✗ picker usability varies with screen readers
For a date people know by heart, typing three numbers beats navigating a calendar back several decades.

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 validation order The three parts are checked for presence, digits and ranges, combined into a real calendar date, compared with today, and finally checked against the minimum age. Presence day, month, year all given Digits + ranges month 1–12, year 4 digits Real date day ≤ days in that month Past and plausible not future, not 120+ years Minimum age ageOn(birth, today) ≥ 18
Checks run from the most basic to the most specific, so the first failure reported is always the one the user can fix first.

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
When a 29 February birthday turns 18 A timeline around late February and early March of a non-leap year showing the date on which someone born on 29 February 2008 turns 18 under the 28 February policy and under the 1 March policy. 28 Feb policy turns 18 1 Mar policy turns 18 Calendar 28 Feb 1 Mar 0 1 2 3 4 5 6 days from 25 Feb 2026
In a non-leap year a leap-day birthday falls either on 28 February or 1 March depending on policy; the code must pick one deliberately.

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.

Date of birth fieldset with an error A sign-up form with the date of birth collected in day, month and year fields, the month field flagged with an out-of-range error and the ARIA wiring annotated. Your details We need your date of birth to confirm you are 18 or over. For example, 27 3 2007 1 Day 27 Month 13 2 ✗ Month must be between 1 and 12 Year 2001 Continue 1 Legend and hint are referenced by the fieldset's aria-describedby 2 setCustomValidity goes on the month input so reportValidity focuses it 3 autocomplete bday-day, bday-month and bday-year let browsers fill all three
The error names the part to fix and focus lands on it; the legend explains why the date is needed.

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.

← Back to Date and Time Validation