(last updated: )

HTTP Basic and Digest authentication with PHP

Note: this article is pretty dated. Many things in here are probably still correct, but in 2018 and beyond it probably makes a lot more sense to try and find a composer package that does this for you.

HTTP authentication is quite popular for web applications. It is pretty easy to implement and works for a range of http applications; not to mention your browser.

Basic Auth

The two main authentication schemes are ‘basic’ and ‘digest’. Basic is pretty easy to implement and appears to be the most common:

<?php

$username = null;
$password = null;

// mod_php
if (isset($_SERVER['PHP_AUTH_USER'])) {
    $username = $_SERVER['PHP_AUTH_USER'];
    $password = $_SERVER['PHP_AUTH_PW'];

// most other servers
} elseif (isset($_SERVER['HTTP_AUTHORIZATION'])) {

        if (strpos(strtolower($_SERVER['HTTP_AUTHORIZATION']),'basic')===0)
          list($username,$password) = explode(':',base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6)));

}

if (is_null($username)) {

    header('WWW-Authenticate: Basic realm="My Realm"');
    header('HTTP/1.0 401 Unauthorized');
    echo 'Text to send if user hits Cancel button';

    die();

} else {
    echo "<p>Hello {$username}.</p>";
    echo "<p>You entered {$password} as your password.</p>";
}

?>

Well it’s a bit difficult I suppose, but you might have noticed the username and password are sent over the wire using base64 encoding. Not really secure, unless you have SSL in place.

Digest

Digest is designed to be more secure. The password is never sent over the wire in plain text, but rather as a hash. The implications of the usage of a hash is that it can never be decrypted. We can only validate the hash by applying the same hash function to the password we have. If the hashes match, the password was correct.

Lets first see how Digest auth should work:

Client requests url

GET / HTTP/1.1

Server requires authentication

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Digest realm="The batcave",
  qop="auth",
  nonce="4993927ba6279",
  opaque="d8ea7aa61a1693024c4cc3a516f49b3c"

Client authenticates

GET / HTTP/1.1
Authorization: Digest username="admin",
  realm="The batcave",
  nonce=49938e61ccaa4,
  uri="/",
  response="98ccab4542f284c00a79b5957baaff23",
  opaque="d8ea7aa61a1693024c4cc3a516f49b3c",
  qop=auth, nc=00000001,
  cnonce="8d1b34edb475994b"

Information coming from the server:

realmA string which will be used within the UI and as part of the hash.
qopCan be auth and auth-int and has influence on how the hash is created. We use auth.
nonceA unique code, which will be used within the hash and needs to be sent back by the client.
opaqueThis can be treated as a session id. If this changes the browser will deauthenticate the user.

Information from the client

usernameThe supplied username
realmSame as server response.
nonceSame as server response.
uriThe authentication uri
responseThe validation hash.
opaqueSame as server response.
qopSame as server response.
ncNonce-count. This a hexadecimal serial number for the request. The client should increase this number by one for every request.
cnonceA unique id generated by the client

So how do we know if the password was correct? We van validate using the following formula (pseudo code).

A1 = md5(username:realm:password)
A2 = md5(request-method:uri) // request method = GET, POST, etc.
Hash = md5(A1:nonce:nc:cnonce:qop:A2)

if (Hash == response)
  //success!
else
  //failure!

Or, using PHP:

<?php

$realm = 'The batcave';

// Just a random id
$nonce = uniqid();

// Get the digest from the http header
$digest = getDigest();

// If there was no digest, show login
if (is_null($digest)) requireLogin($realm,$nonce);

$digestParts = digestParse($digest);

$validUser = 'admin';
$validPass = '1234';

// Based on all the info we gathered we can figure out what the response should be
$A1 = md5("{$validUser}:{$realm}:{$validPass}");
$A2 = md5("{$_SERVER['REQUEST_METHOD']}:{$digestParts['uri']}");

$validResponse = md5("{$A1}:{$digestParts['nonce']}:{$digestParts['nc']}:{$digestParts['cnonce']}:{$digestParts['qop']}:{$A2}");

if ($digestParts['response']!=$validResponse) requireLogin($realm,$nonce);

// We're in!
echo 'Well done sir, you made it all the way through the login!';

// This function returns the digest string
function getDigest() {

    // mod_php
    if (isset($_SERVER['PHP_AUTH_DIGEST'])) {
        $digest = $_SERVER['PHP_AUTH_DIGEST'];
    // most other servers
    } elseif (isset($_SERVER['HTTP_AUTHORIZATION'])) {

            if (strpos(strtolower($_SERVER['HTTP_AUTHORIZATION']),'digest')===0)
              $digest = substr($_SERVER['HTTP_AUTHORIZATION'], 7);
    }

    return $digest;

}

// This function forces a login prompt
function requireLogin($realm,$nonce) {
    header('WWW-Authenticate: Digest realm="' . $realm . '",qop="auth",nonce="' . $nonce . '",opaque="' . md5($realm) . '"');
    header('HTTP/1.0 401 Unauthorized');
    echo 'Text to send if user hits Cancel button';
    die();
}

// This function extracts the separate values from the digest string
function digestParse($digest) {
    // protect against missing data
    $needed_parts = array('nonce'=>1, 'nc'=>1, 'cnonce'=>1, 'qop'=>1, 'username'=>1, 'uri'=>1, 'response'=>1);
    $data = array();

    preg_match_all('@(\w+)=(?:(?:")([^"]+)"|([^\s,$]+))@', $digest, $matches, PREG_SET_ORDER);

    foreach ($matches as $m) {
        $data[$m[1]] = $m[2] ? $m[2] : $m[3];
        unset($needed_parts[$m[1]]);
    }

    return $needed_parts ? false : $data;
}

?>

As you can see we need to have a plain-text version of the password in order to validate the user. It’s not a good idea to store the plain-text password, therefore it’s strongly recommended to store the result of $A1 instead.

Security improvements

  • It’s smart to validate the contents of opaque, nonce and realm. If you have the data stored on the server, why not check it.
  • The nc should be an ever increasing number. You could store the number and track to make sure it doesn’t make any big jumps. It’s not wanted to be extremely strict about the sequence, because you might miss a number, and requests could come in be out of order.
  • ‘qop’ is quality of protection. This serves as an integrity code for the request. A hacker could steal all your HTTP Digest headers and simply change the body to make it do something else. If ‘qop’ is set to ‘auth’, only the requested uri will be taken into consideration. If ‘qop’ is ‘auth-int’ the body of the request will also be used in the hash. (A2 = md5(request-method:uri:md5(request-body))).

References

Web mentions

Comments

  • Richard Heyes

    Richard Heyes

    The PEAR package Auth_SASL I wrote may be of some interest, implementing a variety of SASL authentication methods: http://pear.php.net/package/Auth_SASL
  • sak

    sak

    the problem with HTTP auth is how do you logout ?
  • Scott MacVicar

    Scott MacVicar

    Your digest authentication is open to a replay attack, you can do a few things to solve this. 1. Keep track of any previous nonce values and make sure they're not re-used. 2. Attach a timestamp to the nonce and if a timestamp it outwith a defined time frame, reject it. 3. Add in the IP address as part of the nonce and check it on the next request. I recommend a combination of at least two though.
  • Evert

    Evert

    Richard, I'll definitely take a look. Especially in relation to some of the points Scott just mentioned :) Sak, the trick is to just trigger another HTTP 401. If you do this, the user will be forced to reauthenticate. Scott, 1 & 2 make sense, both definitely a bit tedious to implement. Curious to how other people do this..
  • Dan

    Dan

    I am curious too about how to logout. Any ideas on this? I don't want to just log in as one user. Is it possible to set a bad nonce on log out request?
  • Evert

    Evert

    As mentioned before, all you really need to do is trigger a 401. As a starting point you could use: http://code.google.com/p/sabredav/source/browse/trunk/lib/Sabre/HTTP/ And call ->requireLogin();
  • Max

    Max

    About logout. I can throw 401 but trouble happens when user press "cancel" on authentication form and then browser's "back". In this case browser does not purge authentication cache and user still logged in.
  • Evandro Oliveira

    Evandro Oliveira

    Hi Evert. Nice help! First time here, and obviously found ur article via google... Wanna suggest a little correction: "(...) $A1 = md5("{$digestParts['username']}:{$realm}(...)" $digestParts['username'] should be replaced by $validUser, don't??? else any username'll be validated by the correct pwd. Changed it here and sucessfully works. a simple workaround to do logout function is merge http-auth with php session... just start a session at line 1 and create an if to check a logout function to close the section.... sorry 4 bad english... and thank you again...
  • Evert

    Evert

    Evandro, that's a very good point. I'm fixing this :)
  • Evert

    Evert

    Note that in real-life, you would actually check the username coming in from the request, then fetch the password information. So the only reason why this would be somewhat of a security issue, is if you really only just have 1 hardcoded username + pass.
  • Ivan

    <p>Good example!</p>
  • Roger Qiu

    <p>Could you expand on this part:</p><p>} elseif (isset($_SERVER['HTTP_AUTHENTICATION'])) {</p><p> if (strpos(strtolower($_SERVER['HTTP_AUTHENTICATION']),'digest')===0)<br> $digest = substr($_SERVER['HTTP_AUTHORIZATION'], 7);<br>}</p><p>I've never seen $_SERVER['HTTP_AUTHENTICATION'] in the wild. And wouldn't getallheaders() PHP 5.4 deal with that?</p>
    • Evert

      Evert

      <p>Roger,</p><p>It suppose getallheaders() may give that information. But this blogpost was written in 2009, way before PHP 5.4 was out.</p><p>This specific code actually still lives on though, but it's been rewritten and refactored a number of times. The workaround is still in there though, along with a few new ones for other situations:</p><p><a href="https://github.com/fruux/sabre-http/blob/master/lib/Sabre/HTTP/Request.php#L109" rel="nofollow noopener" title="https://github.com/fruux/sabre-http/blob/master/lib/Sabre/HTTP/Request.php#L109">https://github.com/fruux/sa...</a></p>
  • Thomas Wacker

    <p>Excellent. The best article on that topic i found, so far. And i was was searching a while.</p>
  • Paul M. Jones

    <p>Evert, should that be HTTP_AUTHORIZATION and not HTTP_AUTHENTICATION in the "Basic" example?</p>
    • Evert

      Evert

      <p>Yes, it totally should. Weird that this got unnoticed for 5 years. Fixed the example. That code eventually made it's way to here:</p><p><a href="https://github.com/fruux/sabre-http/blob/master/lib/Sabre/HTTP/Auth/Basic.php" rel="nofollow noopener" title="https://github.com/fruux/sabre-http/blob/master/lib/Sabre/HTTP/Auth/Basic.php">https://github.com/fruux/sa...</a></p><p>EDIT: looks like someone did point it out 8 months ago, but I missed the first half of his comment.</p>
  • Matt Robinson

    <p>Thank you good sir! Works like a charm.</p>
  • jejjcop

    <p>Thank you, worked great!</p>
  • Eduardo Domanski

    <p>Hi Evert,</p><p>I've been running some examples to understand Digest authentication, and realized that, for some unknown reason, realm gets lost and when my "digest.php" (server side) try to compare the hash sent with the one it generated, they don't match. After some tests, I figured it out. My client's hash is generated with no value for the Realm, but I have no idea why. Do you why is why this happening?</p><p>Apache has the Digest mod enabled;</p><p>Here are the headers:</p><p>* Hostname localhost was found in DNS cache<br>* Trying ::1...<br>* Connected to localhost (::1) port 80 (#0)<br>* Server auth using Digest with user 'admin'<br>&gt; PUT /testes/digest.php HTTP/1.1<br>Host: localhost<br>Accept: */*<br>&lt; HTTP/1.1 401 Unauthorized<br>&lt; Date: Wed, 03 Jun 2015 17:20:35 GMT<br>&lt; Server: Apache/2.4.12 (Win32) PHP/5.6.9<br>&lt; X-Powered-By: PHP/5.6.9<br>&lt; WWW-Authenticate: Digest realm="Restricted area",qop="auth",nonce="556f3763cb0c5",opaque="cdce8a5c95a1427d74df7acbf41c9ce0"<br>&lt; Content-Length: 39<br>&lt; Content-Type: text/html; charset=UTF-8<br>&lt; <br>* Ignoring the response-body<br>* Connection #0 to host localhost left intact</p><p>* Issue another request to this URL: 'http://localhost/testes/digest.php'<br>* Found bundle for host localhost: 0x3634758<br>* Re-using existing connection! (#0) with host localhost<br>* Connected to localhost (::1) port 80 (#0)<br>* Server auth using Digest with user 'admin'<br>&gt; PUT /testes/digest.php HTTP/1.1</p><p>Host: localhost<br>Authorization: Digest username="admin",realm="",nonce="556f3763cb0c5",uri="/testes/digest.php",cnonce="3a878588e10c08bd236afc6f2fb54cad",nc=00000001,response="a7832b1009be21cd46225ea659a20243",qop="auth",opaque="cdce8a5c95a1427d74df7acbf41c9ce0"</p><p>Accept: */*<br>&lt; HTTP/1.1 200 OK //Here it prints the message Wrong credentials (as I'm running the <a href="http://php.net" rel="nofollow noopener" title="php.net">php.net</a> digest example.)<br>&lt; Date: Wed, 03 Jun 2015 17:20:35 GMT<br>&lt; Server: Apache/2.4.12 (Win32) PHP/5.6.9<br>&lt; X-Powered-By: PHP/5.6.9<br>&lt; Content-Length: 20<br>&lt; Content-Type: text/html; charset=UTF-8</p><p>&lt;</p><p>* Connection #0 to host localhost left intact</p><p>Thank you</p>
  • BMA C-I-C

    <p>Works like a champ! :)</p>
  • BMA C-I-C

    <p>Can the digest script be modified to have an array of valid usernames and passwords from a database?</p>