PHP Programming/Building a secure user login system

Many beginning PHP programmers set out to build a website that features a user login system but are unaware of the awaiting pitfalls. Below is a step-by-step guide through the necessary components of both a user authentication system and a user authorization system. The former is about determining whether users are who they say they are, while the latter is concerned with whether or not users are allowed to do what they are trying to do (e.g. gain access to a particular page, or execute a particular query).



There are two parts to the authentication process:

  1. The login form. The user is presented with some way of entering their credentials; the system checks these against a list of known users; if a match is found, the user is authenticated. This part of the system generally also initiates some way of remembering that a user is authenticated (such as by setting a cookie) so that this process doesn't have to be repeated for each request.
  2. The per-request check (for want of a better name!). This is the same as the second part of the login form process, with the user credentials being acquired from a source more convenient to the user—such as the cookie.

The code given below may need adjustment depending on the architecture of your scripts, whether object-oriented or procedural, having a single entry-point into your code or a dozen scripts each called separately. However the components of a login system are executed, their fundamentals are the same. Similarly, when a 'database' is referred to, it does not necessarily mean MySQL or any other RDBMS; user information could be stored in flat files, on an LDAP server, or in some other way.

The Login Form


This is the simplest part of the system, and the easiest place to start. Put simply: an HTML form is presented to the user, the user enters their credentials, the form contents are submitted to the next part of the login system for processing.

The user's credentials are generally a username and password, although others are possible (such as a nonce from a hardware token-generator). Many sites are now using the user's email address rather than a username. The advantages of this are that the e-mail addresses will be unique to one user, and it allows people to have consistent usernames since users with common usernames may not be able to get the same username everywhere they register.

The HTML for a basic login form could be something like this:
<form action="/login" method="post">
    <label for="email">Email address:</label>
    <input id="email" type="text" name="email" />
    <label for="password">Password:</label>
    <input id="password" type="password" name="password" />
    <input type="submit" name="login" value="Login" />

The data from the login form can be submitted to whatever script is to handle the authentication and login process, and it is in this script that the real business of logging-in takes place.

We don't need worry about checking inputs when logging in as much as when creating or changing the account since we're just matching what's entered in with what's in the database. Anything that is illegal (such as an e-mail address without an @ sign) will simply not have a match and the login will fail. All user-submitted data will be safely escaped when it's passed to the database so we don't, at this point, need to worry about SQL injection attacks and there is no security benefit to be gained from limiting the length or form of the username or password.

The login processing script itself needs to do a number of things. Firstly, it initiates a session (and this will often actually form part of the 'per-request authentication', as described below). Secondly, it queries the database for a matching user; if there is none then the login attempt has failed and the user is returned to the login form. Lastly (and assuming the user does exist), their identifier (username or email address) is saved as a session variable.

The fundamental actions of the login script are show here.
$db = new Database(); // Database abstraction class.
$email_address = $db->esc($_POST['email']);
$password = $db->esc($_POST['password']);
$matching_users = $db->get_num_rows("SELECT 1 FROM `users` WHERE email_address='$email_address' AND password=crypt('$password', password) LIMIT 1");
if ($matching_users) {
    // User exists; log user in.
    $_SESSION['email_address'] = $email_address;
    echo "You are now logged in.";
} else {
    // Login failed; re-display login form.
  • The Database database abstraction class is used to hide database implementation details for the purpose of this example, and should not be taken to represent any actual, existing library class.
  • Where the Database class' get_num_rows($sql) method returns a count of the rows resulting from the $sql query. The LIMIT 1 above means that this will return only 0 or 1.

The script can now display a welcome message, and the authentication will stay with the user so he doesn't have to login every single time he loads a page. Let us now look at how to do that.

Per-request Check


The users authenticity must be verified upon each HTTP request (i.e. for a page, image, or whatever). This is ostensibly as simple as seeing whether the relevant session variable is set:

The basic authentication check that needs to be run for each user request.
if (!isset($_SESSION['email_address']))
    // User is not logged in, so send user away.
// User is logged in; private code goes here.

This is sufficient in many ways, but it is vulnerable to a number of attacks. It relies utterly upon the session being tied to the right user. This is not a good thing, because sessions can be hijacked (the session key can be stolen by a third party) or fixed (a third party can force a user to use a session key that the third party knows). Read the sessions page of this Wikibook for more information about this and how to avoid it.

The basic authentication check, with additional guards against session hijacking or fixation.
$timeout = 60 * 30; // In seconds, i.e. 30 minutes.
$fingerprint = hash_hmac('sha256', $_SERVER['HTTP_USER_AGENT'], hash('sha256', $_SERVER['REMOTE_ADDR'], true));
if (    (isset($_SESSION['last_active']) && $_SESSION['last_active']<(time()-$timeout))
     || (isset($_SESSION['fingerprint']) && $_SESSION['fingerprint']!=$fingerprint)
     || isset($_GET['logout'])
    setcookie(session_name(), '', time()-3600, '/');
$_SESSION['last_active'] = time();
$_SESSION['fingerprint'] = $fingerprint;
// User authenticated at this point (i.e. $_SESSION['email_address'] can be trusted).

The $_SESSION['last_active'] and $_SESSION['fingerprint'] variables will also need to be set at the initial log in point (where the login form was processed) if they are to be used; simply insert the following lines above where the email_address variable is set:

The code used to check if the user is the same user that logged in.
$fingerprint             = hash_hmac('sha256', $_SERVER['HTTP_USER_AGENT'], hash('sha256', $_SERVER['REMOTE_ADDR'], true));
$_SESSION['last_active'] = time();
$_SESSION['fingerprint'] = $fingerprint;

One thing to remember when using browser fingerprints, however, is that, while they do add some security to your application, they are not a catchall by any means. Many ISPs offer Dynamic IP addresses, which are IP address that change at certain intervals. If this were to happen while a user is browsing your page, he would be kicked out of his account. Also, the code snippet that checks to make sure the browser is the same, can be modified via Firefox extensions that modify the headers while requesting the page.

And that is how a secure user-authentication system is implemented in PHP5! There are a number of points that have been glossed over in the information above, and some that have been left out completely (e.g. how to only start a session when necessary). If you are implementing your own user login system and are trying to follow the advice given here, there are sure to be things that you will figure out as you go and will wish were explained here, so please be bold and edit this page to add anything that you feel is missing.



To do:

  • ACLs
  • User-, group-, role-based authorization.
  • Other stuff…