Locking Users Out After Invalid Login Attempts: Django & a Cache

We’ve all done it. Forgotten our passwords and been locked out of an account. It’s irritating when it’s actually you, but it does serve a purpose — namely it will significantly slow down any malicious actors trying to use brute force attacks to access accounts on your system. So let’s jump in!

In order to do this we need to keep track of a few things. We’ll talk about the specifics of how we can store this information in the cache later, but for now, let’s assume we’re tracking this information:

  • The user’s invalid login attempts. We don’t need to keep track of all of them indefinitely, we only need to keep track of the ones that were made within the time range, up to the number of invalid attempts they’re allowed to make. To be more specific, if we lock a user out after 5 invalid login attempts within a 10 minute time period, we don’t need to keep track of more than 5 attempts, and we don’t need to keep track of invalid attempts that were more than 10 minutes ago.
  • When the user’s lockout started, if they’ve been locked out.

Here are the things that could happen when a user hits the login view with a POST request, and what we should do (a fun flow chart + some words for folks who aren’t visual learners!):

  • If they’ve been locked out, make sure the timestamp of the lockout start is within the duration of the lockout.
  • If it’s not, delete their entry from the cache, they can start fresh
  • If their lockout timestamp is within the lockout duration time period, we don’t need to process their request at all — even if the credentials are correct, we’re not going to log them in, because they’re still locked out

If they’re not locked out:

  • Process the request — if their credentials are correct, log them in.
  • If they’re incorrect, check the cache to see if they’ve already got an entry. If they don’t have an entry, we’ll add one with the timestamp for now to their timestamp bucket

If they’ve entered invalid credentials and already have an entry in the cache:

  • Go through the timestamps that are currently in their timestamp bucket and remove any that were longer ago than our time period (15 minutes, in our example)
  • Add now to the timestamp bucke
  • Check how many entries are in the timestamp bucket. If it’s equal to the number of invalid attempts they’re allowed (it really shouldn’t ever end up being longer than that, since they’re locked out at that point, but I guess it can’t hurt to do a >= comparison…), add a lockout timestamp to their cache and display an error message.

If you haven’t used Django’s caching system before, I recommend reading up on how it works a bit before we jump in. Basic usage examples can be found here, and the rest of that page has a ton more information if you need it!

All the examples below assume that a user will be locked out for 15 minutes after 5 invalid login attempts within a 10 minute period. Numbers are hardcoded accordingly. You probably want to handle these magic numbers however you handle magic numbers within your codebase.

I like to create an object for each different caching use case — that way I can just pass in the information I know it needs and let it generate the keys and values accordingly, without worrying about remembering what format I’ve used or repeating logic in various places. Here’s what this cache might look like, commentary to follow:

import logging

from django.core.cache import cache

logger = logging.getLogger(__name__)

class InvalidLoginAttemptsCache(object):    

    @staticmethod    
    def _key(email):        
        return 'invalid_login_attempt_{}'.format(email)    

    @staticmethod    
    def _value(lockout_timestamp, timebucket):        
        return {            
            'lockout_start': lockout_timestamp,
            'invalid_attempt_timestamps': timebucket        
        }    

    @staticmethod    
    def delete(email):        
        try:            
            cache.delete(InvalidLoginAttemptsCache._key(email))               except Exception as e:            
            logger.exception(e.message)    

    @staticmethod    
    def set(email, timebucket, lockout_timestamp=None):        
        try:            
            key = InvalidLoginAttemptsCache._key(email)            
            value = InvalidLoginAttemptsCache._value(lockout_timestamp, timebucket)                   cache.set(key, value)        
        except Exception as e:            
            logger.exception(e.message)    

    @staticmethod    
    def get(email):        
        try:            
            key = InvalidLoginAttemptsCache._key(email)            
            return cache.get(key)        
        except Exception as e:            
            logger.exception(e.message)
  • The _key method returns a string used as the cache key — I use a base identifier as the beginning of the key (invalid_login_attempt_) to differentiate between other things you keep in the cache, followed by a unique identifier for the user. If you have a multi-tenant application, you may need to include more than just the user’s email address in the cache key — such which domain they’re trying to log in on or which tenant they belong to (in case they access multiple domains, you wouldn’t want to lock them out of one for getting their password wrong on the other!). Just keep in mind as you’re passing this information around, it needs to be serializable, so pass around IDs or strings, not database objects.
  • The _value method returns the dictionary containing when they were locked out (if applicable), and the list of timestamps of their invalid login attempts. These values are passed into the .set function from the view, and just assigned here.
  • The set function does just what it sounds like — adds the key/value pair to your cache. You can optionally add a third argument to tell the cache when this key/value pair expires — I’d recommend either how long a user is locked out if they are, or the time period during which their invalid login attempts are counted, but make sure you use the longer of these two values!
  • get also does just what it sounds like — looks in the cache to see if that value exists and returns it if it does, and returns None otherwise.
  • delete, as you might expect, removes the entry from the cache based on the key. You can use this in an admin page that allows administrators to unlock users, as well as to remove the cache entry after the requisite time period has lapsed and the user’s lockout has expired.

Cool, let’s put our cache to use! When a user tries to log in, there are some things we can do before we even check to see if their credentials are correct. The below assumes a user is locked out for 15 minutes after their invalid login attempts, and covers this portion of our flow chart above:

import arrow

# get the email from the form or POST data
locked_out = False
cache_results = InvalidLoginAttemptsCache.get(email)
if cache_results and cache_results.get('lockout_start'):
    lockout_start = arrow.get(cache_results.get('lockout_start'))    
    locked_out = lockout_start >= arrow.utcnow().shift(minutes=-15)
    if not locked_out:        
        InvalidLoginAttemptsCache.delete(email, domain_id)    
    else:        
        # Add an error to the form to let the user know they're locked out and can't log in. Code to do this will vary depending on whether you're in a view or the form class itself
else:    
    # If they don't have an entry in the cache, we know they're not locked out, and we can process their request

I’m going to skip the step where they’re not currently locked out and their credentials are correct and assume you’ve already got a functioning success-path login flow. Let’s look at some code for this part instead:

cache_results = InvalidLoginAttemptsCache.get(email)
lockout_timestamp = None
now = arrow.utcnow()
invalid_attempt_timestamps = cache_results['invalid_attempt_timestamps'] if cache_results else []

# clear any invalid login attempts from the timestamp bucket that were longer ago than the range 
invalid_attempt_timestamps = [timestamp for timestamp in invalid_attempt_timestamps if timestamp > now.shift(minutes=-15).timestamp]

# add this current invalid login attempt to the timestamp bucket
invalid_attempt_timestamps.append(now.timestamp)

# check to see if the user has enough invalid login attempts to lock them out
if len(invalid_attempt_timestamps) >= 5:    
    lockout_timestamp = now.timestamp    
    # This is also where you'll need to add an error to the form to both prevent their successful authentication and let the user know

# Add a cache entry. If they've already got one, this will overwrite it, otherwise it's a new one
InvalidLoginAttemptsCache.set(email, invalid_attempt_timestamps, lockout_timestamp)
  • As you’ve probably noticed, this approach depends on a caching system. If an attacker could figure out how to bring down your caching backend, you wouldn’t be able to access your cache, and would therefore have no way to lock users out, presenting a security risk
  • If you’re using Django’s built in login flow & form, how to access the form class itself to add an error to it is not an obvious thing. How to do that is outside the scope of this post, but I’d love to hear if folks have questions about this, and I can write up a separate post about that!
  • You’ll want to make sure you lock accounts that don’t exist as well as those that do. That is, if a user tries to log in using an email address or username that doesn’t exist in your system the requisite number of times, you’ll want to lock that “account” as well. Otherwise you’re giving potential attackers important information about which accounts exist (and they should continue trying to target), and which don’t.