Rate limiting is a rucial security mechanism that controls how much network traffic can flow through a system. When building web applications, one of the most important places to implement rate limiting is in your authentication system.
Consider what happens when users log into your application. Without any restrictions, someone (or something) could attempt to log in hundreds or even thousands of times per minute. This is where rate limiting becomes essential. It allows you to set specific boundaries around how many login attempts a user or automated program can make within a certain timeframe. This measured approach protects your system from various security threats and helps maintain stable performance.
Bots and brute force attacks
Before diving into implementation, let's understand two key concepts that are central to authentication security: bots and brute force attacks.
Bots are automated software applications programmed to perform repetitive tasks at speeds far beyond human capability. In the context of authentication, malicious bots can be particularly dangerous because they can make thousands of login requests per minute, attempting to break into user accounts.
These automated attacks often employ a technique called brute force: a methodical approach where the bot systematically tries different password combinations until it finds the correct one. Without rate limiting, a single bot could test millions of password combinations in the time it would take a human to try just a handful.
This is precisely why rate limiting is crucial for authentication endpoints. While rate limiting applies to all login attempts - whether from legitimate users or automated systems - its primary purpose is to defend against malicious bots.
By restricting the number of login attempts within a specific timeframe, we create an effective defense against these automated attacks. Even if a bot can generate thousands of password combinations, rate limiting ensures it can only test a few of them within any given window of time, making brute force attacks impractical.
Implementing rate limit
Now that we understand why rate limiting is crucial for authentication security, let's explore how to implement it using the "fixed window" strategy.
In the "fixed window" strategy, we track the number of login attempts within a predefined time period, and this count is reset after the time period expires.
For example, if we allow 5 login attempts in a one-minute window, a user could make:
- 5 login attempts between, say, 2:00:00 and 2:00:59
- Then the counter would reset at 2:01:00
- Another 5 login attempts would be possible between 2:01:00 and 2:01:59
But how do we know where these login attempts are coming from? We use the client's IP address as the unique identifier. In the implementation below, the authRateLimit()
function accepts the IP address as an argument:
const rateLimitStore = new Map<string, { count: number; lastRequest: number }>()
// These values determine how many requests a user can make within a time window
const MAX_AUTH_REQUESTS = 5 // Allow 5 requests
const WINDOW_IN_SECONDS = 60 // Within a 60-second window
/************************************************
*
* Auth rate limit
*
************************************************/
export async function authRateLimit(
ip: string
): Promise<{ limited: boolean; retryAfter?: number; message?: string }> {
const key = `auth:${ip}`
const now = Date.now()
const currentLimit = rateLimitStore.get(key)
if (!currentLimit) {
// First time request for this IP
// Initialise their counter and start their window
rateLimitStore.set(key, {
count: 1,
lastRequest: now,
})
return { limited: false }
}
// Convert our window from seconds to milliseconds for comparison
const windowMs = WINDOW_IN_SECONDS * 1000
// Calculate when the current window expires
const windowExpiry = currentLimit.lastRequest + windowMs
if (now > windowExpiry) {
// The previous window has expired
// Start a fresh window with a reset counter
rateLimitStore.set(key, {
count: 1,
lastRequest: now,
})
return { limited: false }
}
// We're still in the current window
// Check if the user has exceeded their request limit
if (currentLimit.count >= MAX_AUTH_REQUESTS) {
// User has made too many requests
const retryAfter = Math.ceil((windowExpiry - now) / 1000)
return {
limited: true,
retryAfter,
message: `Too many sign-in attempts. Please try again in ${retryAfter} seconds.`,
}
}
// User hasn't exceeded their limit
// Increment their counter but keep them in the same window
rateLimitStore.set(key, {
count: currentLimit.count + 1,
lastRequest: currentLimit.lastRequest,
})
return { limited: false }
}
To help you better understand how the function evaluates each request, I have made a diagram:
One important aspect to note about our implementation is its storage mechanism. Currently, I'am using a Map
data structure to store rate limiting information directly in memory. You might want to consider alternatives like a database or a key-value store such as Redis. The choice between these storage options will depend on your specific requirements, such as whether you need persistence across server restarts or need to share rate limit information across multiple servers.
It's also worth noting that while I've implemented this solution in TypeScript, the core logic can be adapted to any programming language.