Developed by Simon Fearby https://www.fearby.com to allow PHP developers to integrate haveibeenpwned exposed password checks into their websites sign up’s (or logins). Get the latest version of this code from https://github.com/SimonFearby/phphaveibeenpwned/.
Update 2018: For the best performing VM host (UpCloud) read my guide on the awesome UpCloud VM hosts (get $25 free credit by signing up here).
This demonstrates a PHP framework less way (using HTML 5, Javascript and PHP) to validate a password by hashing (SHA1) the password (before the HTML form is submitted). A part of the password hash is checked at https://api.pwnedpasswords.com/range/{[}xxxxx} API (before a decision to save the form data is made). A Password exposed result returns the user to the sign-up form and no match completes the submission process.
SHA is performed on the password entered in the HTML form in Javascript (Your password never leaves the browser) and the PHP submit receiver performs a partial hash check at api.pwnedpasswords.com. Only a fraction of your password hash is sent to api.pwnedpasswords.com and only a partial hash of your password is returned with other partial matches (Making it hard (for anyone listening) to know what password you used).
This demo does not enforce SSL, sanitize, validate any form data or save the password to a database etc. The aim of this page is to demonstrate integration with api.pwnedpasswords.com. This demo displays a password strength meter. signup_submit.php allows you to enable debugging to see what is going on (detected errors are sent back to the submit.php and alerts shown.
Basics
signup.php – Main PHP file with a form with basic Javascript validation.
signup_submit.php – The form submit sends the form data here and calls the pwnedpasswords API
signup_ok.php – Is loaded if the password is not exposed
The initial HTML code was generated with the Platforma GUI web generator. I added to the Javascript and relevant code. the HTML input field types are set to “text” (not “password”) so you can see the passwords. A password SHA1 hash is generated on form submission and the user’s password never leaves the browser.
A SHA1 hash of the password is updated and displayed (I am using the jsSHA library).
<script type="text/javascript" src="./js/sha/sha1.js"></script>
After a basic HTML Javascript form validation is performed the uses password is replaced with a hash then the form is submitted (to signup_submit.php).
// Generate SHA1 Hash var shaObj = new jsSHA("SHA-1", "TEXT"); shaObj.update(document.forms["submitform"]["password1"].value); var passwordhash = shaObj.getHash("HEX"); document.getElementById("sha135").value = passwordhash;
signup_submit.php then takes the password hash, get the first 5 chars and fires up a curl connection to https://api.pwnedpasswords.com/range/$data when the data returns PHP checks the haveibeenpwned API body for matches of the matching password hashed and compared the known hash with the passwords has. Read more about how the API works here.
The PHP function that does the AI check is located here
function sendPostToPwnedPasswordsCom($data) { $curl = curl_init(); // Init Curl Object if (defined('ENABLE_DEBUG_OUTPUT') && true === ENABLE_DEBUG_OUTPUT) { echo "Data to Send: $data <br />"; echo "Sending Data to: https://api.pwnedpasswords.com/range/$data <br />"; } // Set Curl Options: http://php.net/manual/en/function.curl-setopt.php curl_setopt($curl, CURLOPT_URL, "https://api.pwnedpasswords.com/range/$data"); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_FRESH_CONNECT, true); // TRUE to force the use of a new connection instead of a cached one. curl_setopt($curl, CURLOPT_FORBID_REUSE, true); // TRUE to force the connection to explicitly close when it has finished processing, and not be pooled for reuse. curl_setopt($curl, CURLOPT_TIMEOUT, 10); curl_setopt($curl, CURLOPT_MAXREDIRS, 10); curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); // TRUE to follow any "Location: " header that the server sends as part of the HTTP header (note this is recursive, // PHP will follow as many "Location: " headers that it is sent, unless CURLOPT_MAXREDIRS is set). // Make a request to the api.pwnedpasswords.com $http_request_result = curl_exec ($curl); $http_return_code = curl_getinfo($curl, CURLINFO_HTTP_CODE); // api.pwnedpasswords.com Response codes /* Semantic HTTP response code are used to indicate the result of the search: Code Description 200 Ok — everything worked and there's a string array of pwned sites for the account 400 Bad request — the account does not comply with an acceptable format (i.e. it's an empty string) 403 Forbidden — no user agent has been specified in the request 404 Not found — the account could not be found and has therefore not been pwned 429 Too many requests — the rate limit has been exceeded */ // Change the return code to debug //$http_return_code = 429; if (defined('ENABLE_DEBUG_OUTPUT') && true === ENABLE_DEBUG_OUTPUT) { echo "Return HTTP CODE: $http_return_code <br />"; } // What was the http response code from api.pwnedpasswords.com if ($http_return_code == 200) { // OK (All other return codes direct the user back with an error) if (defined('ENABLE_DEBUG_OUTPUT') && true === ENABLE_DEBUG_OUTPUT) { echo "Return HTTP Data site: " . strlen($http_request_result) . " bytes. <br />"; } } elseif ($http_return_code == 400) { // api.pwnedpasswords.com: API Bad Request if (defined('ENABLE_DEBUG_OUTPUT') && true === ENABLE_DEBUG_OUTPUT) { echo "API Bad Request <br />"; } header("Location: signup.php?Error=PwnedpasswordsAPIBadRequest&code=" . $http_return_code); die(); } elseif ($http_return_code == 403) { // api.pwnedpasswords.com: API Bad User Agent if (defined('ENABLE_DEBUG_OUTPUT') && true === ENABLE_DEBUG_OUTPUT) { echo "API Bad User Agent <br />"; } header("Location: signup.php?Error=PwnedpasswordsAPIBadUserAgent&code=" . $http_return_code); die(); } elseif ($http_return_code == 404) { // api.pwnedpasswords.com: API User Not Found, not needed in his password hash check but we may as well catch now if (defined('ENABLE_DEBUG_OUTPUT') && true === ENABLE_DEBUG_OUTPUT) { echo "API User Not Found <br />"; } header("Location: signup.php?Error=PwnedpasswordsAPIuserNotFound&code=" . $http_return_code); die(); } elseif ($http_return_code == 429) { // api.pwnedpasswords.com: API Too Many Requests if (defined('ENABLE_DEBUG_OUTPUT') && true === ENABLE_DEBUG_OUTPUT) { echo "API Too Many Requests</ br>"; } header("Location: signup.php?Error=PwnedpasswordsAPIuserTooManyRequests&code=" . $http_return_code); die(); } else { // api.pwnedpasswords.com: API Down if (defined('ENABLE_DEBUG_OUTPUT') && true === ENABLE_DEBUG_OUTPUT) { echo "API Down!</ br>"; } header("Location: signup.php?Error=PwnedpasswordsAPIDown&code=" . $http_return_code); die(); } // Tidy up the curl object and return the request api body curl_close ($curl); return $http_request_result; }
You can enable debugging in signup_submit.php if you wish (but if an echo has presented debug data and a header redirect happens it will produce an error).
// define('ENABLE_DEBUG_OUTPUT', true); define('ENABLE_DEBUG_OUTPUT', false);
echo statements are wrapped
if (defined('ENABLE_DEBUG_OUTPUT') && true === ENABLE_DEBUG_OUTPUT) { echo "API Bad Request <br />"; }
signup_submit.php will redirect the browser back to signup.php if an error is found
header("Location: signup.php?Error=PwnedpasswordsAPIuserTooManyRequests&code=" . $http_return_code); die();
A success event (no password hash found) the user is sent to ignup_ok.php
header("Location: signup_ok.php"); die();
signup.php will check for the Error query string
Then display bootstrap alert errors for each error case
if ($error == "PwnedpasswordsAPIuserTooManyRequests") { echo '<div class="alert alert-danger" role="alert">'; echo '<br /><a target="_blank" href="https://api.pwnedpasswords.com">https://api.pwnedpasswords.com</a> reported too many requests to the API from this IP, please wait a few seconds and try again.<br /> (E009)<br />'; echo '<br />Please consider donating to Troy Hunt <a target="_blank" href="https://www.troyhunt.com/donations-why-i-dont-need-them-and-why/">https://www.troyhunt.com/donations-why-i-dont-need-them-and-why/</a> (<em>developer of <a target="_blank" href="https://haveibeenpwned.com">https://haveibeenpwned.com</a></em>).<br />'; echo '</div>'; }
if ($error == "PwnedpasswordsAPIuserTooManyRequests") { echo '<div class="alert alert-danger" role="alert">'; echo '<br /><a target="_blank" href="https://api.pwnedpasswords.com">https://api.pwnedpasswords.com</a> reported too many requests to the API from this IP, please wait a few seconds and try again.<br /> (E009)<br />'; echo '<br />Please consider donating to Troy Hunt <a target="_blank" href="https://www.troyhunt.com/donations-why-i-dont-need-them-and-why/">https://www.troyhunt.com/donations-why-i-dont-need-them-and-why/</a> (<em>developer of <a target="_blank" href="https://haveibeenpwned.com">https://haveibeenpwned.com</a></em>).<br />'; echo '</div>'; }
Sample Errors
Sample Password exposed to error.
Sample API Offline alert
If form field needs attention a JavaScript event is written to set the focus etc.
if ($error == "PasswordExposed") { echo '<script>'; echo 'document.getElementById("password1").focus();'; echo 'document.getElementById("password1").select();'; echo '</script>'; }
If no password hash has been matched with pwnedpasswords the user is directed to signup_ok.php (not very exciting but that’s your jobs to integrate it with your system and harden).
Sample debugging output
Get the code: https://github.com/SimonFearby/phphaveibeenpwned/
More Reading
If you are using Ubuntu don’t forget to set up a free SSL cert, setting up an SSL cert on OSX is also a good idea. I have guides on setting up an Ubuntu server on AWS, Digital Ocean and Vultr. I love Vultr VM hosts and have blogged about setting up WordPress via the CLI, uploading files with SSH, restoring Vultr Snapshots etc.
I hope this guide helps someone.
Ask a question or recommend an article
[contact-form-7 id=”30″ title=”Ask a Question”]
Revision History
v1.0 Initial post