Next Article
Alphabetical A–Z Listings from MySQL in PHP (Without 27 Queries)
16 January 2018
A registration form is the first thing most people build with PHP, and it is where most security mistakes are learned. This guide covers the whole flow — client-side validation with no library, and a PHP backend that gets password storage, enumeration and session handling right.
Separate registration from login. Combining them behind a radio toggle, as the earlier version of this article did, makes both flows harder to reason about and leaks which emails are registered.
<form id="register-form" method="post" action="/register" novalidate>
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrfToken) ?>" />
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" required
autocomplete="email" aria-describedby="email-error" />
<p class="error" id="email-error" role="alert"></p>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required
minlength="12" autocomplete="new-password" aria-describedby="password-hint password-error" />
<p class="hint" id="password-hint">At least 12 characters. Longer is better than complicated.</p>
<p class="error" id="password-error" role="alert"></p>
</div>
<button type="submit">Create account</button>
</form>
Details that matter:
type="email" gives free validation and the right mobile keyboard. The original used type="text".autocomplete="new-password" tells password managers to offer generation; current-password on the login form tells them to fill.novalidate suppresses the browser's default bubbles so you can render errors yourself — validation still runs, you just control the display.aria-describedby links errors to their input so screen readers announce them.id must be unique. The original had two submit buttons both with id="pass", which is invalid HTML and makes getElementById unpredictable.The original required five characters. Current NIST guidance is a minimum of eight, and 12 or more is a sensible product default. Crucially, NIST now recommends against forcing composition rules — mandatory symbols and digits push people toward Password1!, which is weaker than a long passphrase. Length and a breached-password check beat complexity rules.
jQuery Validate solved a real problem in 2011. Browsers now do it natively through the Constraint Validation API:
const form = document.getElementById('register-form');
const messages = {
email: {
valueMissing: 'Enter your email address.',
typeMismatch: 'That does not look like a valid email address.',
},
password: {
valueMissing: 'Choose a password.',
tooShort: 'Passwords must be at least 12 characters.',
},
};
function showError(input) {
const target = document.getElementById(`${input.id}-error`);
const rules = messages[input.name] ?? {};
// validity is a ValidityState object — check which constraint failed.
const key = Object.keys(rules).find(k => input.validity[k]);
const text = key ? rules[key] : (input.validationMessage || '');
target.textContent = text;
input.setAttribute('aria-invalid', text ? 'true' : 'false');
return !text;
}
// Validate on blur, and re-validate on input once a field has errored.
form.querySelectorAll('input').forEach((input) => {
input.addEventListener('blur', () => showError(input));
input.addEventListener('input', () => {
if (input.getAttribute('aria-invalid') === 'true') showError(input);
});
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const inputs = [...form.querySelectorAll('input[required]')];
const allValid = inputs.map(showError).every(Boolean);
if (!allValid) {
inputs.find(i => i.getAttribute('aria-invalid') === 'true')?.focus();
return;
}
const button = form.querySelector('button');
button.disabled = true;
button.textContent = 'Creating account…';
try {
const res = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' },
});
const data = await res.json();
if (!res.ok) {
document.getElementById('email-error').textContent = data.message ?? 'Something went wrong.';
return;
}
window.location.href = data.redirect;
} catch {
document.getElementById('email-error').textContent = 'Network error. Please try again.';
} finally {
button.disabled = false;
button.textContent = 'Create account';
}
});
No jQuery, no validation plugin, no form plugin — three fewer dependencies than the original, and it works in every browser.
Note the finally block. If the request fails and you never re-enable the button, the user is stuck on a dead form with no way to retry.
<?php
declare(strict_types=1);
session_start();
header('Content-Type: application/json');
// --- CSRF -----------------------------------------------------------------
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit(json_encode(['message' => 'Session expired. Please refresh and try again.']));
}
// --- Validate -------------------------------------------------------------
$email = strtolower(trim($_POST['email'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
$errors = [];
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors[] = 'Enter a valid email address.';
}
if (mb_strlen($password) < 12) {
$errors[] = 'Passwords must be at least 12 characters.';
}
// Reject the passwords attackers try first.
if (in_array(strtolower($password), COMMON_PASSWORDS, true)) {
$errors[] = 'That password is too common. Please choose another.';
}
if ($errors !== []) {
http_response_code(422);
exit(json_encode(['message' => $errors[0]]));
}
// --- Create ---------------------------------------------------------------
// Never store the password. password_hash generates its own salt.
$hash = password_hash($password, PASSWORD_DEFAULT);
try {
$stmt = $pdo->prepare(
'INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, NOW())'
);
$stmt->execute([$email, $hash]);
} catch (PDOException $e) {
// 23000 = unique constraint violation, i.e. the email already exists.
if ($e->getCode() === '23000') {
sendVerificationOrNoticeEmail($email);
http_response_code(200);
exit(json_encode(['redirect' => '/check-your-email']));
}
throw $e;
}
sendVerificationEmail($email);
http_response_code(201);
echo json_encode(['redirect' => '/check-your-email']);
Use password_hash() and password_verify(). Never MD5, never SHA-1, never SHA-256, and never a hand-rolled salt — those are fast hashes, which is exactly wrong for passwords. PASSWORD_DEFAULT currently means bcrypt and will move to whatever is best in future PHP releases.
Note the original's schema declared password varchar(50). A bcrypt hash is 60 characters, so that column silently truncates every hash and no one can ever log in. Use VARCHAR(255).
Look at the duplicate-email branch above: it returns the same response as a successful registration. If you return "that email is already registered", anyone can test addresses against your site and learn who has an account — a genuine privacy leak, especially for sites where membership is sensitive.
Instead, always say "check your email", and vary the message you actually send: a verification link for new accounts, or a "someone tried to register with your address; here is a password reset link" notice for existing ones.
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
// Always run a verification, even when no user exists, so the response time
// does not reveal whether the account is registered.
$dummy = '$2y$12$usesomesillystringfoeediegahustringholdplacevalue00000000';
$valid = password_verify($password, $user['password_hash'] ?? $dummy);
if (!$user || !$valid) {
// One generic message for both cases.
http_response_code(401);
exit(json_encode(['message' => 'Invalid email or password.']));
}
// Rehash if the cost factor or algorithm has since changed.
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
}
// Prevent session fixation — issue a fresh session id on privilege change.
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
echo json_encode(['redirect' => '/dashboard']);
The dummy hash matters more than it looks. Without it, a failed lookup returns immediately while a real one spends ~100ms hashing — a measurable difference that tells an attacker the address exists.
Without it, none of the above stops someone trying a million passwords. Track failures per account and per IP:
CREATE TABLE login_attempts ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, ip_hash VARBINARY(32) NOT NULL, attempted_at DATETIME NOT NULL, KEY idx_email_time (email, attempted_at), KEY idx_ip_time (ip_hash, attempted_at) );
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM login_attempts
WHERE email = ? AND attempted_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE)'
);
$stmt->execute([$email]);
if ((int) $stmt->fetchColumn() >= 5) {
http_response_code(429);
exit(json_encode(['message' => 'Too many attempts. Try again in 15 minutes.']));
}
Throttle rather than lock permanently — a permanent lock on failed attempts lets an attacker deny service to any account by deliberately failing.
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // HTTPS only
'httponly' => true, // unreachable from JavaScript, blunting XSS
'samesite' => 'Lax', // blocks most CSRF
]);
session_start();
password_hash() / password_verify() — never a fast hash, never your own salt.VARCHAR(255) for the hash column.session_regenerate_id(true) on login.Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.