Skip to main content
Being Idea Innovations
A Secure Registration and Login Form with PHP (No jQuery)
Back to Blog

A Secure Registration and Login Form with PHP (No jQuery)

3 January 20186 min readPHP
Share:

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.

The form

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.
  • Every id must be unique. The original had two submit buttons both with id="pass", which is invalid HTML and makes getElementById unpredictable.

On minimum length

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.

Client-side validation without a library

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.

The PHP backend

<?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']);

Password hashing

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).

Preventing user enumeration

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.

The login handler

$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.

Rate limiting

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 cookie settings

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();

Checklist

  • password_hash() / password_verify() — never a fast hash, never your own salt.
  • VARCHAR(255) for the hash column.
  • Prepared statements everywhere.
  • Identical responses for existing and new emails.
  • Constant-time login regardless of whether the account exists.
  • session_regenerate_id(true) on login.
  • CSRF token on every state-changing POST.
  • Rate limit by account and by IP.
  • Minimum length over composition rules.
  • Validate on the client for feedback, on the server for safety.
Share:

Written by

Amit Verma

Founder / 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 Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers