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. That is a real 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 for global markets and a hands-on role in every architecture decision.
More articles by AmitYou might also like
We ship software that scales. Let's work together.