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.
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.
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.
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.
Related Guides
- Identity Text Field Validation — how usernames differ from names, emails and URLs.
- Cancelling Stale Requests with AbortController — the abort pattern behind the availability check.
- Throttling vs Debouncing Server Validation — choosing the timing for the lookup.
- Rate Limiting Async Validation Endpoints — protecting the availability endpoint.