Sane Password Strength Validation for Django with zxcvbn

While many admins and blog posts tell users that length is by far the most important factor in creating strong passwords/passphrases, the majority of password input fields are giving them a set of hide-bound rules: Eight characters, at least one upper- and one lowercase letter, some digits and punctuation marks, etc.

Even though it includes dictionary words, a passphrase like:

Sgt. Pepper's Mr. Kite

is far stronger than:

js72(.Tb8

(there’s a world of difference between 22 characters and 9, from a cracking perspective). But many password input fields would reject the first one. No wonder users are confused by the process of creating strong passwords!

A while back, Dropbox released a very smart password validation library called zxcvbn (check the bottom left row of your keyboard) which measures strength as “entropy,” rather than as a function of adherence to a particular set of rules.

password_common
Here the overall password strength is heavily punished for including common strings, despite its length.

Because it lets users build up a strong password either by making it long or making it chaotic, I’m convinced that tools like zxcvbn are the way forward, but implementing them in pure Javascript presents a problem: To be effective, they have to do lots of dictionary lookups, and to cross-reference most-used password strings in order to penalize them. These dictionaries are too large to transmit to the browser/client quickly.

Happily, zxcvbn has been ported to work as a back-end lib. Dropbox has released a python port of zxcvbn.

password_strong_long
Despite looking very non-random and including dictionary strings, this password gets high marks for its length and absence of commonly-used strings like “qwerty” or “abc” or common people’s names.

Running zxcvbn on the backend eliminates the dictionary size problem, but introduces a new one: You want to be able to preserve back-end field validation but still provide real-time feedback in the client. I’ve put together a kit for Django that satisfies both goals (yes there are other Django implementations for zxcvbn but none of them worked quite how I wanted).

First let’s take care of traditional/non-JS field validation, just so we’re safe. We can keep this minimal and do the fancy work on the front end later. First install the module:

pip install python-zxcvbn

And set a minimum strength threshold in settings that will apply identically to front- and back-end validation. In settings.py:

PASSWORD_MINIMUM_ENTROPY = 35

You can tweak that number to suit your purposes later. Now create a simple form object. In forms.py:

from zxcvbn import password_strength

class SinglePasswordForm(forms.Form):

    password = forms.CharField()

    def clean_password(self):
        # Password strength testing mostly done in JS; minimal validation here.
        password = self.cleaned_data.get('password')
        results = password_strength(password)

        if results['entropy'] < settings.PASSWORD_MINIMUM_ENTROPY:
            raise forms.ValidationError("Entropy score {s} too low (must be {m}".format(
                s=results['entropy'], m=settings.PASSWORD_MINIMUM_ENTROPY
                )
            )

        return password

A couple of things to note there: First, I’m setting this up as a CharField rather than as a PasswordField. Second, I’m only using one password field, not two. Web forms generally use two password fields, but that’s only because password fields usually show stars, so it’s very easy for the user to mess up. We’re taking a different approach here. We want the user to experiment, and you can’t do that easily if the characters are stars. In the vast majority of situations, the user is typing in private. Let’s go with Jakob Nielsen here and make it easy for them!

Go ahead and implement that form on a test page and verify that your traditional field validation works as expected. Not super user friendly from a user feedback perspective, but it makes your form safe for the rare user (or hacker) with Javascript disabled.

We will of course provide a toggle later for users who want/need to hide the field:

password_hidden

Now to get fancy. To support real-time feedback, a strength indicator bar, hints and suggestions, and to disable the Submit button until our desired strength is reached, let’s create a fast/lightweight JSON endpoint to validate against. In urls.py:

url(r'^json_password_validator/$', views.json_password_validator),

And in views.py, create an endpoint that takes a password and returns some JSON that includes zxcvbn’s rich dataset explaining how it reached its conclusions, a simple valid boolean, and our site-wide threshold setting so we can access it easily in the JS:

from django.http import JsonResponse
from django.conf import settings
from zxcvbn import password_strength
...

@csrf_exempt
def json_password_validator(request):

    valid = False
    min_strength = settings.PASSWORD_MINIMUM_ENTROPY

    if request.method == 'GET':
        password = request.GET["password"]
        results = password_strength(password)

        if results['entropy'] > min_strength:
            valid = True

        return JsonResponse({
            'valid': valid,
            'min_strength': min_strength,
            'results': results}
            )

Note that zxcvbn can also take a list of user_inputs such as their first and last name, which will be heavily penalized if used. My working version includes code to add those factors, when available.

You should now be able to hit a URL like:

http://127.0.0.1:8000/json_password_validator/?password=abc123

and get back a useful dataset. e.g.:

passwordjson

With that working, all we have to do is call it via jQuery as the password is being typed into the field. But we don’t want to mercilessly hammer the server. A safer approach would be to just call the endpoint when the user stops typing for a given interval. For this, I installed jquery-debounce (available through bower, if you like) and set the “stop typing” interval to 250ms. Season to taste.

// Recalculate after slight keypress delay
$('#id_password').keyup( $.debounce( calcScore, 250 ) );

… where calcScore is a function that calls the endpoint and performs a whole bunch of chocolatey interactive password strength goodness. Here’s the full Django html template code and included JS script file I used in my project. I’m using Bootstrap here – season to taste.

{# password_select.html #}
<div class="panel panel-info">
    <div class="panel-heading">
        <h3 class="panel-title">Select a password</h3>
    </div>

    <div class="panel-body">
        <p>Your password is used for logging into most [organization] sites and services (single sign-on).</p>

        <p>Password strength is a combination of factors such as length, presence in the dictionary, presence of mixed case letters, digits, and punctuation marks. Length is the most important factor.</p>

        <p>Experiment with making your password more complex until the bar goes green. Go for length or go for randomness, your choice. When the Submit button is enabled, you're good to go!</p>
    </div>
</div>

<div class="row">
    <div class="col-lg-12">
        <form role="form" class="form" action="" method="post">
            {% csrf_token %}
            <div class="form-group">
                <label class="control-label" for="id_password">Password</label>
                <div class="input-group">
                    <span class="input-group-addon">
                        <input type="checkbox" aria-label="show-hide-password" id="show_hide_password">
                    </span>
                    <input class=" form-control" id="id_password" name="password" type="text">
                    <span class="input-group-btn">
                        <button type="submit" class="btn btn-primary">Submit</button>
                    </span>
                </div>
                <p class="help-block">Toggle checkbox to show/hide password</p>
            </div>
        </form>
    </div>
</div>

<div class="row">
    <div class="col-lg-12">
        <p><strong>Strength</strong></p>
        {# Progress bar values interpolated in JS #}
        <div class="progress">
            <div class="progress-bar" id="progress-bar"
                role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
                <span class="sr-only"></span>
            </div>
        </div>
    </div>
</div>

{# Status messages re: password strength as list items #}
<div class="row">
    <div class="col-lg-12">
        <ul id="form_status_messages"></ul>
    </div>
</div>

And the javascript. Go nuts.

/*
    Parses and displays password strength results from internal JSON endpoint which uses
    the zxcvbn fuzzy password lib: https://github.com/dropbox/python-zxcvbn
*/

// Start with disabled Submit button
$(".btn-primary").prop("disabled", true);

// Let user show/hide password field
$("#show_hide_password").click(function(){
    togglePasswordFieldClicked();
});

function calcScore() {

    var password = $(this).val();
    var valid = false;

    // URIEncode request so special chars aren't handled as anchors or URL params.
    $.ajax({
        type : 'GET',
        url: '/json_password_validator/?password='.concat(encodeURIComponent(password)),
        contentType : 'application/json',
        complete: function(data) {

            valid = data.responseJSON['valid'];  // `valid` will be true or false, matching project settings
            min_entropy = parseInt(data.responseJSON['min_strength']);  // Passed in from project settings
            results = data.responseJSON['results'];
            entropy = parseInt(results['entropy']);
            crack_time_display = results['crack_time_display'];

            /*
            How do we measure strength? We need nn/100 for the progress bar.
            Score and crack_time are too jumpy, so we use the relationship between
            current entropy and the project-wide minimum entropy setting for our scale.
            But we want the bar to go into the green at 4/5 progress, or 80%.
            Therefore, the entroy multiplier is 80 / min_entropy.
            */

            strength = Math.floor(entropy * (80/min_entropy));

            // Vars used in progress bar
            var stylestring = 'width: xx%'.replace('xx', strength);

            // Bootstrap classes for progress bar colors
            if (strength >= 80) {
                var status = "success";
            } else if (strength >= 60 && strength < 80) {
                var status = "info";
            } else if (strength >= 40 && strength < 60) {
                var status = "warning";
            } else if (strength < 40) {
                var status = "danger";
            }

            // Send hints and info to suggestions div, clearing first
            $('#form_status_messages').empty();
            $('#form_status_messages').append("<li class=\"text-danger\">Complexity score: " + entropy + "</li>");
            $('#form_status_messages').append("<li class=\"text-danger\">Length: " + password.length + "</li>");
            $('#form_status_messages').append("<li class=\"text-danger\">Theoretically crackable in: " + crack_time_display + "</li>");

            // Additional hints
            if (valid == false) {
                $('#form_status_messages').append("<li class=\"text-info\">Hint: Try adding words - short sentences are great!</li>");
                $('#form_status_messages').append("<li class=\"text-info\">Hint: Try scrambling a sequence</li>");
                $('#form_status_messages').append("<li class=\"text-info\">Hint: Try adding punctuation or digits</li>");
                $('#form_status_messages').append("<li class=\"text-info\">Hint: Longer is better!</li>");
            };

            // Debugging: Show full API response on the page
            // $('#form_status_messages').append("<pre>" + JSON.stringify(results) + "</pre>");

            // Re-draw the progress bar
            barclass = 'progress-bar-'.concat(status); // For Bootstrap
            $('#progress-bar').attr('style', stylestring);
            $('#progress-bar').attr('aria-valuenow', strength);

            // So classes don't accumulate, remove them all and replace with new one
            $('#progress-bar').removeClass().addClass("progress-bar xx".replace('xx', barclass));

            // When min threshold met, announce and enable Submit button
            if (valid == true) {
                $('#form_status_messages').append("<li class=\"text-success\"><strong>Password meets minimum strength requirement!</strong></li>");
                $(".btn-primary").prop("disabled", false);
            };

            // Remove status text and re-disable Submit if valid drops below threshold
            if (valid == false) {
                $('#progress-bar').text('');
                $(".btn-primary").prop("disabled", true);
            };

            // Clear suggestions if password field emptied
            if (password.length == 0) {
                $('#form_status_messages').empty();
            };
        }
    });
}

// Recalculate after slight keypress delay
$('#id_password').keyup( $.debounce( calcScore, 250 ) );

Update: Project Callisto is using exactly this implementation for their own site.

2 Replies to “Sane Password Strength Validation for Django with zxcvbn”

  1. Very detailed guide. I used to work in a college of thousands of users, and password strength was always an issue. There days we try to look at multi-factor authentication, but of course, bio data has to be stored somewhere too!

Leave a Reply

Your email address will not be published. Required fields are marked *