Without ratelimiting OTP endpoints you are exposed to brute force attacks, learn how to secure the endpoints using a ratelimiter.
Written by
James Perkins
Published on
A One-Time Password (OTP) is a unique code valid for only one login session or transaction. It adds an extra layer of security by preventing fraudulent access to your accounts, even if someone else knows your password. You've likely encountered OTPs many times. For instance, when logging into your bank account from a new device, you may receive an OTP via SMS or email, which you must enter to verify your identity. Another typical example is the login flow, where instead of entering a password, an OTP is sent to your email.
Without ratelimiting, an attacker could try several OTPs in quick succession in a so-called 'brute force attack' to find the right one to gain access to an account.
By limiting the number of OTP attempts within a specific timeframe, it becomes practically impossible for an attacker to guess the right OTP before it expires.
create_namespace
, limit
If you prefer, you can use our example here and skip the entire tutorial below. Also, if you want to see it live, you can see an implementation below using Unkey and Resend here
Before we begin with the tutorial, it should be stated that OTP implementations will have two separate requests: sending the OTP via email or SMS and verifying the request.
Let’s start with the sending of an OTP. Below is an insecure OTP implementation with a fake email that sends a random 6-digit code to the user via a next.js server action.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
"use server";
import { randomInt } from "crypto";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendOTP(formData: FormData) {
try {
const email = formData.get("email") as string | null;
if (!email) {
return {
success: false,
error: "Email was not supplied, please try again",
statusCode: 400,
};
}
const otp = randomInt(100000, 999999).toString();
const { data, error } = await emails.send({
from: "james@unkey.com",
to: email,
subject: "OTP code",
text: `Your OTP code is ${otp}`
});
// handled error
if (error) {
console.error(error);
return { success: false, error: "Failed to send email", statusCode: 500 };
}
return {
success: true,
statusCode: 201,
};
//catch
} catch (e) {
return { success: false, error: "Failed to send email", statusCode: 500 };
}
}
First, you’ll need to install the @unkey/ratelimit
package to your project and then add the following imports.
1
2
import { Ratelimit } from "@unkey/ratelimit";
import { headers } from "next/headers";
We will use the headers to retrieve the IP of the requester and use that as an identifier to limit against. Now we need to configure the ratelimiter
1
2
3
4
5
6
7
8
9
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY,
namespace: "otp-send",
limit: 2,
duration: "60s",
})
export async function sendOTP(formData: FormData) {
// sending OTP logic
The above code will configure a new namespace named otp-send
if it doesn’t exist and limit the requests to two per minute. Of course, any number of attempts, but two emails per minute should suffice for the end user.
Now that we have our ratelimiter configured, we can modify the request to first retrieve the IP address; this will check for both the forwarded IP address and the real IP from the headers. We will use the forwarded IP first and fall back to the real IP.
1
2
3
4
5
6
7
8
9
10
11
export async function sendOTP(formData: FormData) {
try {
// check for forwarded
let forwarded-ip = headers().get("x-forwarded-for");
// check for real-ip
let real-ip = headers().get("x-real-ip");
if(forwarded-ip){
forwarded-ip = forwarded_ip.split(/, /)[0]
}
if (real-ip) real_ip = real_ip.trim();
// sending logic below
Now we have access to an identifier, and we can run our rate limit against it. Add the following code before checking if the user has provided an email.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { success, reset } = await unkey.limit(
forwarded-ip || real-ip || "no-ip",
);
const millis = reset - Date.now();
const timeToReset = Math.floor(millis / 1000);
// if this is unsuccesful return a time to reset to the user so they know how long to wait
if (!success) {
return {
success: false,
error: `You can request a new code in ${timeToReset} seconds`,
statusCode: 429,
};
}
const email = formData.get("email") as string | null;
//shortened for tutorial.
You’ll notice that we check for forwarded-ip
and then the real-ip
, and finally, if nothing is available, we will use no-ip
for the fallback. This endpoint is now protected; a user can send two requests per minute. Below is a demo of how you could present this to the user:
The endpoint that verifies an OTP has more potential for brute force attacks; sending codes down with no restriction will give a bad actor plenty of time to try numerous codes to get the right one.
This is where the flexibility of ratelimiting for Unkey can come into play while it is similar to the above server action. For example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
export async function verifyOTP(prevState: any, formData: FormData) {
try {
// check for forwarded
let forwarded-ip = headers().get("x-forwarded-for");
// check for real-ip
let real-ip = headers().get("x-real-ip");
if (forwarded-ip) {
forwarded-ip.split(/, /)[0];
}
if (real-ip) {
real-ip = real_ip.trim();
}
const code = formData.get("code") as string | null;
if (!code) {
return {
success: false,
error: "Code was not supplied, please try again",
statusCode: 400,
};
}
const { success, reset } = await unkey.limit(
forwarded-ip || real-ip || "no-ip",
);
const millis = reset - Date.now();
const timeToReset = Math.floor(millis / 1000);
if (!success) {
return {
success: false,
error: `You have been rate limited, please wait ${timeToReset} seconds and try entering a new code`,
statusCode: 429,
};
}
// Handle verification of your OTP
You can set the limits and namespace to be different, allowing you to be more restrictive and keep all your analytical data separated, for example.
1
2
3
4
5
6
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
namespace: "otp-verify",
limit: 2,
duration: "30s",
});
This operation will allow a user to try twice every 30 seconds before it ratelimits the operation for the IP. Below is an example of how this could look in your application from the example code.
Implementing rate limiting is one thing, but ratelimiting effectively requires following best practices. Here are some tips:
These practices enhance the security and efficiency of OTPs while maintaining a positive user experience.
You can read more about Unkey’s Ratelimiting our documentation, you can see the demo of this in action and test what happens when you go over limits.
2500 verifications and 100K successful rate‑limited requests per month. No CC required.