Build a Passwordless Login System Using WebAuthn and PHP

December 02, 2025
Written by
Isijola Jeremiah
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Build a Passwordless Login System Using WebAuthn and PHP

Passwordless login using WebAuthn is a secure authentication method that eliminates the need for traditional passwords by utilizing public-key cryptography. Instead of entering a password, users authenticate using a trusted device, such as a fingerprint scanner, facial recognition, or a hardware security key.

In this article, you will learn how WebAuthn handles user authentication behind the scenes to enable secure, passwordless logins using public key cryptography, and how to implement it in PHP.

How WebAuthn works

In a typical WebAuthn workflow, users start by entering their email or username.

The system then checks the data source to see if the user has already registered or not. If the user is new, the system initiates a registration process, prompting them to create a new credential using a trusted device, such as a fingerprint scanner, facial recognition, or a security key.

If the user is already registered, a login process is triggered, allowing them to authenticate using their previously registered device. Once the authentication is successful, the user gains access to their account.

Prerequisites

Make sure you have the following to follow along with the tutorial:

  • PHP 8.3 or above
  • Composer installed globally
  • A web server like Apache with HTTPS support enabled (WebAuthn requires a secure context)
  • A basic understanding of PHP and JavaScript

Set up your PHP environment

To get started, navigate where you usually store your PHP projects and create a new project folder named auth in the directory by running the following commands:

mkdir auth
cd auth

Install the WebAuthn PHP library

To use the WebAuthn PHP library, install the lbuchs/webauthn library, a PHP package that simplifies WebAuthn integration. To do so, run the command below:

composer require lbuchs/webauthn

Set up the backend

Now, let's write the backend code that sets up the necessary server-side environment for running a WebAuthn-based passwordless authentication system using PHP. To do so, create a file named init.php inyour project directory and add the following code to the file:

<?php

$rp = [
  "name" => "webauthn_php",
  "id" => "localhost"
];

session_start();

require "vendor/autoload.php";

$WebAuthn = new lbuchs\WebAuthn\WebAuthn($rp["name"], $rp["id"]);

In the backend code above:

  • The $rp array defines the relying party details, where name is set to webauthn_php and id to localhost where name is a human-friendly label for the application, set to webauthn_php, and id, typically, matches the domain name (e.g., localhost during development) and is set to localhost. These details help identify the server or application during the WebAuthn registration and authentication process. In the context of WebAuthn, a Relying Party (RP) refers to the web application or server that relies on the authenticator such as a fingerprint scanner or security key to verify a user's identity. The RP is responsible for initiating the authentication process and validating the results.
  • The session_start() function starts a PHP session. This is essential for storing temporary data during the authentication process, such as challenges or session variables used to verify the user's interaction.
  • Using lbuchs\WebAuthn\WebAuthn, we created a new instance of the WebAuthn class to manage the authentication processes by utilizing the provided relying party information. This instance forms the core of the backend logic for handling passwordless login sessions.

Handle the WebAuthn registration and server side login

To manage user registration and authentication using passkeys on the server side, you need to create two PHP scripts. These scripts will handle requests from the frontend, interact with the WebAuthn library, and manage user credentials as well as session data.

To get started, navigate to your project root and create two new files: register.php and validate.php.

Next, add the following code to the register.php file:

<?php

require "init.php";

$email = $_POST["email"] ?? "";
$name = $_POST["name"] ?? "";

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    exit("Invalid email");
}

$userFile = "users/" . md5($email) . ".txt";

switch ($_POST["phase"]) {
  case "a":
    if (file_exists($userFile)) {
      exit("User already registered");
    }
    $uid = bin2hex(random_bytes(8));
    $args = $WebAuthn->getCreateArgs(hex2bin($uid), $email, $name, 30, false, true, true);
    $_SESSION["challenge"] = $WebAuthn->getChallenge()->getBinaryString();
    $_SESSION["email"] = $email;
    $_SESSION["uid"] = $uid;
    $_SESSION["name"] = $name;
    echo json_encode($args);
    break;

  case "b":
    if (!isset($_SESSION["email"])) {
      exit("No session data");
    }
    try {
      $data = $WebAuthn->processCreate(
        base64_decode($_POST["client"]),
        base64_decode($_POST["attest"]),
        $_SESSION["challenge"],
        true, true, false
      );
      $save = [
        "uid" => $_SESSION["uid"],
        "email" => $_SESSION["email"],
        "name" => $_SESSION["name"],
        "passkey" => $data
      ];
      if (! is_dir("users")) {
        mkdir("users");
      }
      file_put_contents($userFile, serialize($save));
      echo "Registered successfully";
    } catch (Exception $ex) {
      exit($ex->getMessage());
    }
    break;
}

The register.php file manages the server-side logic for WebAuthn registration. It starts by including the init.php file, then retrieves the user's email and name from the POST request data.

In the first phase, the script checks whether a user already exists, using a hashed version of their email as the filename. If the user does not exist, it generates a new challenge and registration options, stores relevant session data, and sends the options to the frontend in JSON format.

In the second phase, the script verifies the registration response received from the client. If the verification is successful, it saves the user's credential information in a serialized file for future authentication.

Next, add the following code to the validate.php file:

<?php

require "init.php";

$email = $_POST["email"] ?? "";

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
  exit("Invalid email");
}

$userFile = "users/" . md5($email) . ".txt";
if (!file_exists($userFile)) {
  exit("User not registered");
}

$userData = unserialize(file_get_contents($userFile));
$cred = $userData["passkey"];

switch ($_POST["phase"]) {
  case "a":
    $args = $WebAuthn->getGetArgs([$cred->credentialId], 30);
    $_SESSION["challenge"] = $WebAuthn->getChallenge()->getBinaryString();
    $_SESSION["email"] = $email;
    echo json_encode($args);
    break;
  case "b":
    try {
      $WebAuthn->processGet(
        base64_decode($_POST["client"]),
        base64_decode($_POST["auth"]),
        base64_decode($_POST["sig"]),
        $cred->credentialPublicKey,
        $_SESSION["challenge"]
      );
      echo "Welcome";
    } catch (Exception $ex) {
      exit($ex->getMessage());
    }
    break;
}

The validate.php script handles the server-side logic for WebAuthn authentication during the login process. It starts by loading the init.php file and retrieving the user's email from the POST request. The script checks whether the provided email is valid and confirms the user's registration by verifying the existence of their corresponding file.

In the first phase, the script generates a login challenge using the user's stored credential ID and sends this challenge to the frontend for the user to complete. In the second phase, it processes the authentication response sent by the client, validating the user’s credentials against the stored public key and the challenge. If the verification is successful, the script welcomes the user; otherwise, it returns an error message.

Create the user authentication interface

To build the user interface for your passwordless login system, create an HTML file named app.html in the auth project directory, and add the following code to the file:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PHP WebAuthn Demo</title>
    <script src="demo.js" defer></script>
    <style>
      * { 
        box-sizing: border-box; 
      }

      body {
        margin: 0; padding: 0;
        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        background: linear-gradient(135deg, #2980b9, #6dd5fa);
        color: #333;
        display: flex; flex-direction: column;
        align-items: center; justify-content: center;
        min-height: 100vh;
      }

      .container {
        background: #fff;
        padding: 40px 30px;
        border-radius: 16px;
        box-shadow: 0 10px 25px rgba(0,0,0,0.1);
        max-width: 400px; width: 90%; margin: 20px;
        animation: fadeIn 0.6s ease-in-out;
      }

      input[type="email"], input[type="text"] {
        width: 100%;
        padding: 12px 15px;
        margin: 10px 0 20px;
        border: 1px solid #ccc;
        border-radius: 8px;
        font-size: 1rem;
        transition: border 0.3s ease;
      }

      input:focus {
        border-color: #2980b9;
        outline: none;
        box-shadow: 0 0 5px rgba(41,128,185,0.4);
      }

      button {
        width: 100%;
        background: #2980b9;
        color: #fff;
        border: none;
        padding: 12px 0;
        border-radius: 8px;
        font-size: 1rem;
        font-weight: bold;
        cursor: pointer;
        transition: background 0.3s ease;
      }

      button:hover { 
        background: #2573a6; 
      }

      .footer {
        margin-top: 10px;
        text-align: center;
        font-size: 0.9rem;
        color: #f2f2f2;
      }

      @keyframes fadeIn {
        from { opacity: 0; transform: translateY(20px); }
        to   { opacity: 1; transform: translateY(0); }
      }
    </style>
  </head>
  <body>
    <div class="container" id="register-section">
      <h2>Register</h2>
      <input type="email" id="reg_email" placeholder="Enter your email" required />
      <input type="text" id="reg_name" placeholder="Full name" required />
      <button onclick="register.a()">Register</button>
    </div>
    <div class="container" id="login-section" style="display: none;">
      <h2>Login</h2>
      <input type="email" id="val_email" placeholder="Enter your email" required />
      <button onclick="validate.a()">Login</button>
    </div>
    <div class="footer">© 2025 PHP WebAuthn Demo</div>
  </body>
</html>

In the code above, the page is titled “PHP WebAuthn Demo”, and the external script demo.js is loaded with defer, it contains the functions that drive the WebAuthn registration and login flows.

The <body> defines two <div class="container"> panels and a footer. The first panel, id="register-section", is visible on load and includes an email input (id="reg_email") and a full‑name input (id="reg_name"), both marked required, plus a Register button that calls register.a().

The second panel, id="login-section", starts hidden via style="display: none;" and contains a required email input (id="val_email") and a Login button that invokes validate.a(). After a successful registration, demo.js hides the register section, reveals the login section, and smoothly scrolls it into view.

Script the frontend

Scripting your project will assist in interacting with the backend through AJAX, communicating with the browser's WebAuthn API, and assist in encoding and decoding the binary data required for credential creation and verification.

To implement the WebAuthn registration and login functionality on the frontend, create a file named demo.js in your project's top-level directory. In this file, add the JavaScript code below:

var helper = {
  atb: b => {
    let u = new Uint8Array(b), s = "";
    for (let i = 0; i < u.byteLength; i++) s += String.fromCharCode(u[i]);
    return btoa(s);
  },

  bta: o => {
    let pre = "=?BINARY?B?", suf = "?=";
    for (let k in o) {
      if (typeof o[k] == "string") {
        let s = o[k];
        if (s.startsWith(pre) && s.endsWith(suf)) {
          let raw = window.atob(s.slice(pre.length, -suf.length)),
              u = new Uint8Array(raw.length);
          for (let i = 0; i < raw.length; i++) u[i] = raw.charCodeAt(i);
          o[k] = u.buffer;
        }
      } else {
        helper.bta(o[k]);
      }
    }
  },

  ajax: (url, data, after) => {
    let form = new FormData();
    for (let [k, v] of Object.entries(data)) form.append(k, v);
    fetch(url, { method: "POST", body: form })
      .then(res => res.text())
      .then(res => after(res))
      .catch(err => { alert("ERROR!"); console.error(err); });
  }
};

var register = {
  a: () => {
    let email = document.getElementById("reg_email").value.trim(),
        name  = document.getElementById("reg_name").value.trim();
    if (!email || !name) { alert("Please fill in all fields."); return; }
    helper.ajax("register.php", { phase: "a", email, name }, async res => {
      try {
        let opts = JSON.parse(res);
        helper.bta(opts);
        let cred = await navigator.credentials.create(opts);
        register.b(cred, email);
      } catch (e) {
        alert(res);
        console.error(e);
      }
    });
  },

  b: (cred, email) => helper.ajax("register.php", {
      phase: "b",
      email,
      transport: cred.response.getTransports ? cred.response.getTransports() : null,
      client: helper.atb(cred.response.clientDataJSON),
      attest: helper.atb(cred.response.attestationObject)
    }, res => {
      alert(res);
      // hide register, show login
      document.getElementById('register-section').style.display = 'none';
      let login = document.getElementById('login-section');
      login.style.display = 'block';
      login.scrollIntoView({ behavior: 'smooth' });
    })
};

var validate = {
  a: () => {
    let email = document.getElementById("val_email").value.trim();
    if (!email) { alert("Please enter your email."); return; }
    helper.ajax("validate.php", { phase: "a", email }, async res => {
      try {
        let opts = JSON.parse(res);
        helper.bta(opts);
        let cred = await navigator.credentials.get(opts);
        validate.b(cred, email);
      } catch (e) {
        alert(res);
        console.error(e);
      }
    });
  },

  b: (cred, email) => helper.ajax("validate.php", {
      phase: "b",
      email,
      id: helper.atb(cred.rawId),
      client: helper.atb(cred.response.clientDataJSON),
      auth: helper.atb(cred.response.authenticatorData),
      sig: helper.atb(cred.response.signature),
      user: cred.response.userHandle ? helper.atb(cred.response.userHandle) : null
    }, res => alert(res))
};

The code implements passwordless authentication via the WebAuthn API, a subset of the browser’s Credential Management API. The register and validate objects orchestrate user registration and login by calling navigator.credentials.create() and navigator.credentials.get(), posting the assertion data to register.php and validate.php, then swapping the UI between the two forms on success.

Test the application

Start your application server. Once your application server has started, open localhost/app.html in your browser to access the application page, input your email and username, and click on the Register button.

Web application's registration form with fields for email and full name, and a Register button on a blue background.

After you submit the form, you will be given several passkey options to choose from to complete the registration. Select any passkey that you prefer, but remember that you will need to log in using the same method.

Windows Security dialog box prompting user to enter PIN for authentication on a webpage.

If registered successfully, you should get an alert telling you “Registered Successfully”.

A webpage shows a registration success notification pop-up with a form containing email and name fields.

You will be redirected to the login page where you are expected to input your email in the email field on the login form and click on the Login button.

Web login page with email input field and Login button

Once you submit the form, you will be asked to login with the passkey method selected used during registration.

Windows security prompt asking for PIN to verify identity on localhost webpage.

After successfully validating your login, you will get a welcome message alert on your screen.

Web page displaying a welcome message and login form with email input and login button.

That’s how to build a passwordless Login system using WebAuthn and PHP

In this tutorial, you learned how to build a passwordless authentication system in PHP using WebAuthn. You implemented a secure registration and login flow that uses public key cryptography in place of traditional passwords. By combining frontend JavaScript with PHP backend scripts, you successfully created a seamless and secure user authentication experience without relying on passwords.

Isijola Jeremiah is a developer who specialises in enhancing user experience on both the backend and frontend. Contact him on LinkedIn.

Password icons created by Smashicons on Flaticon.