Advertisement
  1. Code
  2. Coding Fundamentals
  3. Security

How to Build Rate Limiting into Your Web App Login

Scroll to top
Final product imageFinal product imageFinal product image
What You'll Be Creating

While reports vary, The Washington Post reported that the recent iCloud celebrity photo hacking centered around Find My iPhone's unprotected login point:

"...security researchers were said to have found a flaw in iCloud's Find My iPhone feature that didn't cut off brute-force attacks. Apple's statement ... suggests the company doesn't regard that revelation as a problem. And that's a problem, according to security researcher and Washington Post contributor Ashkan Soltani.

I agree. I wish Apple had been more forthcoming; its carefully worded response left room for different interpretations and seemed to blame the victims.

Hackers may have used this iBrute script on GitHub to target celebrity accounts via Find My iPhone; the vulnerability has since been closed.

Since one of the wealthiest corporations in the world didn't allocate the resources to rate limit all of their authentication points, it's likely that some of your web apps don't include rate limiting. In this tutorial, I'll walk through some of the basic concepts of rate limiting and a simple implementation for your PHP-based web application.

How Login Attacks Work

Research from prior hacks has exposed passwords that people tend to use most frequently. Xeno.net publishes a list of the top ten thousand passwords. Their chart below shows that the frequency of common passwords in their top 100 list is 40%, and the top 500 make up 71%. In other words, people commonly use and re-use a small number of passwords; in part, because they are easy to remember and easy to type.

Frequency of Common Passwords - From XenonetFrequency of Common Passwords - From XenonetFrequency of Common Passwords - From Xenonet

That means that even a tiny dictionary attack using just the twenty-five most common passwords could be quite successful when targeting services.

Once a hacker identifies an entry point that allows unlimited login attempts, they can automate high speed, high volume dictionary attacks. If there's no rate limiting, then it becomes easy for hackers to attack with larger and larger dictionaries - or automated algorithms with infinite numbers of permutations.

Furthermore, if personal information about the victim is known e.g. their current partner or pet's name, a hacker can automate attacks of permutations of likely passwords. This is a common vulnerability for celebrities.

Approaches to Rate Limiting

To protect logins, there are a couple of approaches that I recommend as a baseline:

  1. Limit the number of failed attempts for a specific user name
  2. Limit the number of failed attempts by IP address

In both cases, we want to measure failed attempts during a specific window or windows of time e.g. 15 minutes and 24 hours.

One risk to blocking attempts by user name is that the actual user could get locked out of their account. So, we want to make sure we make it possible for the valid user to re-open their account and/or reset their password.

A risk to blocking attempts by IP address is that they are often shared by many people. For example, a university might host both the actual account holder and someone attempting to maliciously hack their account. Blocking an IP address may block the hacker as well as the actual user.

However, one cost to increased security is often a bit of increased inconvenience. You have to decide how strictly to rate limit your services and how easy you want to make it for users to re-open their accounts.

It can be useful to code a secret question into your app which can be used to re-authenticate a user whose account was blocked. Alternately, you can send a password reset to their email (hoping that it's not been compromised).

How to Code Rate Limiting

I've written a bit of code to show you how to rate limit your web applications; my examples are based in the Yii Framework for PHP. Most of the code is applicable to any PHP/MySQL application or framework.

The Failed Login Table

First, we need to create a MySQL table to store information from failed login attempts. The table should store the ip_address of the requesting user, the attempted username or email address used and a timestamp:

1
 $this->createTable($this->tableName, array(
2
    'id' => 'pk',
3
    'ip_address' => 'string NOT NULL',
4
    'username' => 'string NOT NULL',
5
    'created_at' => 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
6
    ), $this->MySqlOptions);

Then, we create a model for the LoginFail table with several methods: add, check and purge.

Recording Failed Login Attempts

Whenever there is a failed login, we'll add a row to the LoginFail table:

1
  public function add($username) {
2
    // add a row to the failed login table with username and IP address

3
    $failure = new LoginFail;
4
    $failure->username = $username;
5
    $failure->ip_address = $this->getUserIP();
6
    $failure->created_at =new CDbExpression('NOW()'); 
7
    $failure->save();
8
    // whenever there is a failed login, purge older failure log

9
    $this->purge();
10
  }

For getUserIP(), I used this code from Stack Overflow.

We can also use the opportunity of a failed login, to purge the table of older records. I do this to prevent the verification checks from slowing down over time. Or, you can implement a purge operation in a background cron task every hour or every day:

1
public function purge($mins=120) {
2
    // purge failed login entries older than $mins

3
    $minutes_ago = (time() - (60*$mins));  // e.g. 120 minutes ago

4
    $criteria=new CDbCriteria();
5
    LoginFail::model()->older_than($minutes_ago)->applyScopes($criteria);
6
    LoginFail::model()->deleteAll($criteria);    
7
}

Checking for Failed Login Attempts

The Yii authentication module I'm using looks like this:

1
public function authenticate($attribute,$params)
2
{
3
	if(!$this->hasErrors())  // we only want to authenticate when no input errors

4
	{      
5
		$identity=new UserIdentity($this->username,$this->password);
6
		$identity->authenticate();
7
		if (LoginFail::model()->check($this->username)) {
8
		    $this->addError("username",UserModule::t("Account access is blocked, please contact support."));  		      
9
	    } else {
10
  		switch($identity->errorCode)
11
  			{
12
  				case UserIdentity::ERROR_NONE:
13
    					$duration=$this->rememberMe ? Yii::app()->controller->module->rememberMeTime : 0;
14
    					Yii::app()->user->login($identity,$duration);  		      
15
  					break;
16
  				case UserIdentity::ERROR_EMAIL_INVALID:
17
  					$this->addError("username",UserModule::t("Email is incorrect."));
18
  					LoginFail::model()->add($this->username);
19
  					break;
20
  				case UserIdentity::ERROR_USERNAME_INVALID:
21
  					$this->addError("username",UserModule::t("Username is incorrect."));
22
  					LoginFail::model()->add($this->username);
23
  					break;
24
    				case UserIdentity::ERROR_PASSWORD_INVALID:
25
    					$this->addError("password",UserModule::t("Password is incorrect."));
26
    					LoginFail::model()->add($this->username);
27
    					break;
28
  				case UserIdentity::ERROR_STATUS_NOTACTIV:
29
  					$this->addError("status",UserModule::t("You account is not activated."));
30
  					break;
31
  				case UserIdentity::ERROR_STATUS_BAN:
32
  					$this->addError("status",UserModule::t("You account is blocked."));
33
  					break;
34
  			} 
35
	    }
36
	}
37
}

Whenever my login code detects an error, I call the method to add details about it to the LoginFail table:

LoginFail::model()->add($this->username);

The verification section is here. This runs with every login attempt:

1
$identity->authenticate();
2
if (LoginFail::model()->check($this->username)) {
3
	$this->addError("username",UserModule::t("Account access is blocked, please contact support."));

You can graft these functions on to your own code's login authentication section.

My verification check looks for a high volume of failed login attempts for the username in question and separately for the IP address being used:

1
  public function check($username) {
2
    // check if failed login threshold has been violated

3
    // for username in last 15 minutes and last hour

4
    // and for IP address in last 15 minutes and last hour

5
    $has_error = false;
6
    $minutes_ago = (time() - (60*15));  // 15 minutes ago

7
    $hours_ago = (time() - (60*60));  // 1 hour ago

8
    $user_ip = $this->getUserIP();
9
    if (LoginFail::model()->since($minutes_ago)->username($username)->count()>=self::FAILS_USERNAME_QUARTER_HOUR) {
10
      $has_error = true;
11
    } else if (LoginFail::model()->since($minutes_ago)->ip_address($user_ip)->count()>=self::FAILS_IP_QUARTER_HOUR) {
12
        $has_error = true;
13
      } else if (LoginFail::model()->since($hours_ago)->username($username)->count()>=self::FAILS_USERNAME_HOUR) {
14
      $has_error = true;
15
    } else if (LoginFail::model()->since($hours_ago)->ip_address($user_ip)->count()>=self::FAILS_IP_HOUR) {
16
        $has_error = true;
17
      }
18
      if ($has_error)
19
          $this->add($username);			
20
      return $has_error;
21
  }

I check rate limits for the last fifteen minutes as well as the last hour. In my example, I allow 3 failed login attempts per fifteen minutes and six per hour for any given username:

1
  const FAILS_USERNAME_HOUR = 6;
2
  const FAILS_USERNAME_QUARTER_HOUR = 3;
3
  const FAILS_IP_HOUR = 24;
4
  const FAILS_IP_QUARTER_HOUR = 12;

Note that my verification checks use Yii's ActiveRecord named scopes to simplify the database query code:

1
// scope of rows since timestamp

2
  public function since($tstamp=0)
3
  {
4
    $this->getDbCriteria()->mergeWith( array(
5
      'condition'=>'(UNIX_TIMESTAMP(created_at)>'.$tstamp.')',
6
    ));
7
      return $this;
8
  }
9
10
  // scope of rows before timestamp

11
  public function older_than($tstamp=0)
12
  {
13
    $this->getDbCriteria()->mergeWith( array(
14
      'condition'=>'(UNIX_TIMESTAMP(created_at)<'.$tstamp.')',
15
    ));
16
      return $this;
17
  }
18
19
  public function username($username='')
20
  {
21
    $this->getDbCriteria()->mergeWith( array(
22
      'condition'=>'(username="'.$username.'")',
23
    ));
24
      return $this;
25
  }
26
27
  public function ip_address($ip_address='')
28
  {
29
    $this->getDbCriteria()->mergeWith( array(
30
      'condition'=>'(ip_address="'.$ip_address.'")',
31
    ));
32
      return $this;
33
  }

I've tried to write these examples so that you can easily customize them. For example, you could leave out the checks for the last hour and rely on the last 15 minute interval. Alternatively, you could change the constants to set higher or lower thresholds for the number of logins per interval. You could also write much more sophisticated algorithms. It's up to you.

With this example, to improve performance, you may want to index the LoginFail table by username and separately by IP address.

My sample code doesn't actually change the status of accounts to blocked or provide functionality for unblocking specific accounts, I'll leave that up to you. If you do implement a blocking and resetting mechanism, you may want to offer functionality to separately block by IP address or by username.

I hope you've found this interesting and useful. Please feel free to post corrections, questions or comments below. I'd be especially interested in alternate approaches. You can also reach me on Twitter @reifman or email me directly.

Credits: iBrute preview photo via Heise Security

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.