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

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 for global markets and a hands-on role in every architecture decision.

More articles by Amit

Want to talk tech?

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

No long-term contracts
Senior-led teams
US · AU · NZ timezone coverage
2-week paid trial sprint