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:
realm | A string which will be used within the UI and as part of the hash. |
qop | Can be auth and auth-int and has influence on how the hash is created. We use auth. |
nonce | A unique code, which will be used within the hash and needs to be sent back by the client. |
opaque | This can be treated as a session id. If this changes the browser will deauthenticate the user. |
Information from the client
username | The supplied username |
realm | Same as server response. |
nonce | Same as server response. |
uri | The authentication uri |
response | The validation hash. |
opaque | Same as server response. |
qop | Same as server response. |
nc | Nonce-count. This a hexadecimal serial number for the request. The client should increase this number by one for every request. |
cnonce | A 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))
).
Comments
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_SASLsak •
the problem with HTTP auth is how do you logout ?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 •
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 •
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 •
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 •
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 •
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 •
Evandro, that's a very good point. I'm fixing this :)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 •
Good example!
Roger Qiu •
Could you expand on this part:
} elseif (isset($_SERVER['HTTP_AUTHENTICATION'])) {
if (strpos(strtolower($_SERVER['HTTP_AUTHENTICATION']),'digest')===0)
$digest = substr($_SERVER['HTTP_AUTHORIZATION'], 7);
}
I've never seen $_SERVER['HTTP_AUTHENTICATION'] in the wild. And wouldn't getallheaders() PHP 5.4 deal with that?
Evert •
Roger,
It suppose getallheaders() may give that information. But this blogpost was written in 2009, way before PHP 5.4 was out.
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:
https://github.com/fruux/sa...
Thomas Wacker •
Excellent. The best article on that topic i found, so far. And i was was searching a while.
Paul M. Jones •
Evert, should that be HTTP_AUTHORIZATION and not HTTP_AUTHENTICATION in the "Basic" example?
Evert •
Yes, it totally should. Weird that this got unnoticed for 5 years. Fixed the example. That code eventually made it's way to here:
https://github.com/fruux/sa...
EDIT: looks like someone did point it out 8 months ago, but I missed the first half of his comment.
Matt Robinson •
Thank you good sir! Works like a charm.
jejjcop •
Thank you, worked great!
Eduardo Domanski •
Hi Evert,
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?
Apache has the Digest mod enabled;
Here are the headers:
* Hostname localhost was found in DNS cache
* Trying ::1...
* Connected to localhost (::1) port 80 (#0)
* Server auth using Digest with user 'admin'
> PUT /testes/digest.php HTTP/1.1
Host: localhost
Accept: */*
< HTTP/1.1 401 Unauthorized
< Date: Wed, 03 Jun 2015 17:20:35 GMT
< Server: Apache/2.4.12 (Win32) PHP/5.6.9
< X-Powered-By: PHP/5.6.9
< WWW-Authenticate: Digest realm="Restricted area",qop="auth",nonce="556f3763cb0c5",opaque="cdce8a5c95a1427d74df7acbf41c9ce0"
< Content-Length: 39
< Content-Type: text/html; charset=UTF-8
<
* Ignoring the response-body
* Connection #0 to host localhost left intact
* Issue another request to this URL: 'http://localhost/testes/digest.php'
* Found bundle for host localhost: 0x3634758
* Re-using existing connection! (#0) with host localhost
* Connected to localhost (::1) port 80 (#0)
* Server auth using Digest with user 'admin'
> PUT /testes/digest.php HTTP/1.1
Host: localhost
Authorization: Digest username="admin",realm="",nonce="556f3763cb0c5",uri="/testes/digest.php",cnonce="3a878588e10c08bd236afc6f2fb54cad",nc=00000001,response="a7832b1009be21cd46225ea659a20243",qop="auth",opaque="cdce8a5c95a1427d74df7acbf41c9ce0"
Accept: */*
< HTTP/1.1 200 OK //Here it prints the message Wrong credentials (as I'm running the php.net digest example.)
< Date: Wed, 03 Jun 2015 17:20:35 GMT
< Server: Apache/2.4.12 (Win32) PHP/5.6.9
< X-Powered-By: PHP/5.6.9
< Content-Length: 20
< Content-Type: text/html; charset=UTF-8
<
* Connection #0 to host localhost left intact
Thank you
BMA C-I-C •
Works like a champ! :)
BMA C-I-C •
Can the digest script be modified to have an array of valid usernames and passwords from a database?