Username Validation Rules and Reserved Names

How do you validate a username so that it is typeable, safe in URLs, impossible to confuse with an official account, and unique — while telling the user exactly which rule they broke? This recipe normalises input with NFKC and case folding, applies a small explicit character set and length rule, blocks reserved and route-colliding names, and runs a debounced, abortable availability check against the server. Every verdict — local or remote — lands in setCustomValidity() on the username input, so the Constraint Validation API and the canonical reportValidity() submit call stay in charge.

When to Use Strict Username Rules

Strict rules are right for any identifier that other people see, type, link to or mention: public profile handles, @mentions, workspace or organisation slugs, subdomain names, and repository or package names. They are not right for display names, which should accept any script, as the identity text field validation topic explains. Use this recipe when:

  • The username appears in URLs (/u/ada), where spaces and most punctuation need escaping and look broken.
  • Users mention each other, so the name must be unambiguous to type on any keyboard.
  • Impersonation is a risk, such as marketplaces, social products and anything with an official support account.

If your product signs people in by email and never shows a username, you may not need one at all — every extra identifier is another thing to forget.

Username checks from cheap to expensive A username is normalised, then checked for length and characters, structure, reserved names and finally availability on the server. Normalise NFKC, lowercase Length + charset 3–30, a–z 0–9 . _ Structure no leading, trailing or doubled . _ Reserved admin, support, routes Availability debounced server lookup setCustomValidity with the specific rule broken
Every local check runs on each keystroke; only a name that passes all of them is sent for the network availability check.

Minimal Working Username Validator

const MIN = 3;
const MAX = 30;

// Reserved: privileged roles, your own routes, and common impersonation targets.
const RESERVED = new Set([
  "admin", "administrator", "root", "system", "support", "help", "security", "abuse", "billing",
  "staff", "moderator", "official", "team", "api", "www", "mail", "ftp", "status",
  "login", "logout", "signup", "register", "settings", "account", "about", "terms", "privacy",
]);

export const usernameKey = (raw: string): string => raw.normalize("NFKC").trim().toLowerCase();

export function usernameRuleError(raw: string): string {
  const v = usernameKey(raw);
  if (v.length === 0) return "Choose a username.";
  if (v.length < MIN) return `Username must be at least ${MIN} characters.`;
  if (v.length > MAX) return `Username must be ${MAX} characters or fewer.`;
  const bad = [...v].find((ch) => !/[a-z0-9._]/.test(ch));
  if (bad) return `"${bad}" can't be used. Use letters a–z, numbers, dots and underscores.`;
  if (/^[._]/.test(v) || /[._]$/.test(v)) return "Username can't start or end with a dot or underscore.";
  if (/[._]{2}/.test(v)) return "Dots and underscores can't be next to each other.";
  if (/^\d+$/.test(v)) return "Username must include at least one letter.";
  const stem = v.replace(/[._\d]/g, "");
  if (RESERVED.has(v) || RESERVED.has(stem)) return "That username is reserved. Please choose another.";
  return "";
}

// Wiring with an abortable availability check
const field = document.querySelector<HTMLInputElement>("#username")!;
const status = document.querySelector<HTMLElement>("#username-status")!; // role="status"
let controller: AbortController | undefined;
let timer: number | undefined;
let lastChecked = { key: "", available: false };

field.addEventListener("input", () => {
  controller?.abort();
  window.clearTimeout(timer);
  const local = usernameRuleError(field.value);
  field.setCustomValidity(local);
  status.textContent = "";
  if (local) return;                                  // no network for locally invalid names

  const key = usernameKey(field.value);
  if (key === lastChecked.key) {                      // reuse a verdict we already have
    field.setCustomValidity(lastChecked.available ? "" : "That username is taken.");
    return;
  }
  field.setCustomValidity("Checking availability…");  // blocks submit while pending
  timer = window.setTimeout(async () => {
    controller = new AbortController();
    try {
      const res = await fetch(`/api/usernames/${encodeURIComponent(key)}`, { method: "HEAD", signal: controller.signal });
      if (usernameKey(field.value) !== key) return;   // user kept typing
      const available = res.status === 404;
      lastChecked = { key, available };
      field.setCustomValidity(available ? "" : "That username is taken.");
      status.textContent = available ? `${key} is available.` : `${key} is taken.`;
    } catch (e) {
      if ((e as DOMException).name === "AbortError") return;
      field.setCustomValidity("");                    // fail open; the server re-checks on submit
    }
  }, 400);
});

field.form!.addEventListener("submit", (event) => {
  if (!field.form!.checkValidity()) {
    event.preventDefault();
    field.form!.reportValidity();
  }
});

The “Checking availability…” custom validity is the detail that most implementations miss: without it, a user who types a name and presses Enter within the debounce window submits a name nobody has checked. The pattern is the same one used for async email availability checks.

Availability check with abort and reuse Keystrokes abort the previous request and restart the debounce; only the final value is checked, and a repeated value reuses the cached verdict instead of calling the server again. User Username field Availability API types "ada_l" local rules pass, validity = "Checking…" HEAD /api/usernames/ada_l types "ada_lo" (aborts previous) HEAD /api/usernames/ada_lo 404 → available, validity cleared
Aborting on every keystroke and comparing the key before applying a result means a slow response for an old value can never overwrite the verdict for the current one.

Username Rule Option Reference

Option Type Default Purpose
MIN / MAX number 3 / 30 Length bounds on the normalised key
Character set RegExp class [a-z0-9._] Typeable everywhere, URL-safe without escaping
Structure rules RegExp no leading/trailing/doubled . _ Avoids .., file-like and hidden-looking names
RESERVED Set<string> roles, routes, targets Blocks impersonation and route collisions
Reserved stem check derived digits and separators removed Catches admin_1, support.team
Debounce number (ms) 400 Wait before the availability request
Pending message string “Checking availability…” Keeps the field invalid until the check returns
Failure mode behaviour fail open The server’s unique index is the real guarantee

Keep RESERVED in a shared module used by the client and the server, and add every top-level route your app has or might have — /settings, /about, /pricing. A user who claims pricing before you launch a pricing page owns your URL.

Verification Steps

import { describe, it, expect } from "vitest";
import { usernameRuleError, usernameKey } from "./username";

describe("username rules", () => {
  it.each(["ada", "ada.lovelace", "ada_1815", "a1b"])("accepts %s", (u) => expect(usernameRuleError(u)).toBe(""));
  it.each([
    ["ab", /at least 3/],
    [".ada", /start or end/],
    ["ada__l", /next to each other/],
    ["12345", /at least one letter/],
    ["Admin", /reserved/],
    ["support.2", /reserved/],
    ["jürgen", /"ü" can't be used/],
  ])("rejects %s", (u, msg) => expect(usernameRuleError(u)).toMatch(msg));
  it("folds full-width characters", () => expect(usernameKey("ada")).toBe("ada"));
});

Edge Cases and Failure Modes

Uniqueness races. Two people can check the same free name within the same second and both see “available”. The browser check is a courtesy; a unique index on the normalised key in the database is the guarantee. Handle the insert conflict by returning a field-level “That username was just taken” error, mapped back as in mapping server field errors to form inputs.

Enumeration. A public availability endpoint lets anyone test whether an account exists. For public handles that is inherent — profiles are public anyway — but for private identifiers rate-limit the endpoint per IP and per session, as described in rate-limiting async validation endpoints.

Editing your own username. On a profile page, the user’s current username exists — it is theirs. Exclude the current account from the availability check, or saving an unchanged profile fails with “taken”.

Mobile keyboards. Without autocapitalize="none", autocorrect="off" and spellcheck="false", phones capitalise the first letter or replace the name with a dictionary word. The normalisation hides the capital, but a “corrected” word is a different username.

Supporting Non-Latin Usernames Deliberately

The ASCII character set above is a deliberate trade-off: it is typeable on every keyboard and immune to cross-script confusables, but it excludes people who would naturally choose a handle in Cyrillic, Greek, Arabic or CJK scripts. If your audience needs those, widen the rule carefully rather than simply allowing \p{L}. Allow letters from one script per username (checking with \p{Script=…} classes), normalise with NFKC, and add a confusables “skeleton” check on the server so раураl (Cyrillic) cannot coexist with paypal. Also check how usernames appear in URLs: internationalised paths are percent-encoded when copied from some browsers, which makes shared links look broken. The name-field side of internationalisation — where no such restrictions are needed — is covered in Unicode-aware name field validation.

ASCII-only versus multi-script usernames Two columns comparing an ASCII-only username policy with a multi-script policy restricted to one script per username. ASCII only • a–z, 0–9, dot, underscore ✓ typeable on every keyboard ✓ no cross-script lookalikes ✗ excludes names in other scripts Multi-script, one per name • letters from a single Unicode script ✓ inclusive for global audiences ✗ needs a confusable skeleton check ✗ percent-encoded in some shared URLs
ASCII-only is simplest and safest; multi-script is more inclusive but needs per-username script checks and server-side confusable detection.

Writing Username Messages That Help

Every rejection should name the rule and, where possible, the offending character. “Invalid username” forces users to guess among five rules; ““ü” can’t be used. Use letters a–z, numbers, dots and underscores” tells them exactly what to change. For availability, suggest alternatives the server has already confirmed are free — ada.lovelace1, ada_l — as buttons that fill the field, rather than leaving the user to guess repeatedly and trigger more lookups. Announce availability once, through a polite status region, when the check completes; never announce “checking” on every keystroke. The general wording rules are in writing clear inline error message copy.

Frequently Asked Questions

What characters should a username allow?

For most products, lowercase letters a to z, digits, dots and underscores, 3 to 30 characters long, with no leading, trailing or doubled separators. This is typeable everywhere, safe in URLs and resistant to lookalike impersonation.

Why normalise usernames with NFKC and lowercase?

So that visually identical names map to one key. NFKC folds compatibility characters such as full-width letters, and lower-casing removes case differences. Store and enforce uniqueness on that key.

How do I stop someone registering admin or support?

Keep a reserved list of privileged roles, your app's routes and common impersonation targets, check it against the normalised name with digits and separators removed, and share the list with the server.

Can two users claim the same username at the same time?

Yes, the browser check cannot prevent it. A unique database index on the normalised key is the real guarantee; handle the insert conflict with a field-level error.

← Back to Identity Text Field Validation