API Protection
I’d like to think it’s a right of passage for any new web developer to build an API. After all, the very first thing I worked on when I started as a web developer was an API, and I’ve learned a lot since then.
APIs are the backbone of basically every web, mobile, and native application ever made. They’re misconceivingly simple to build—especially with modern web frameworks—but it’s incredibly important to protect them at any scale.
Three ways I expect every API to be protected is through authorization, input validation, and rate limiting. Let’s dive into each.
Authorization
The very first thing you should be enforcing in your endpoint is authorization. It makes no sense to proceed with any processing or logic if the user is not authorized to communicate with the endpoint in the first place.
There’s a variety of ways to implement authorization, such as a bearer token, API key, or encrypting session data into the user’s cookies.
export async function POST(request: NextRequest) {
const authorized = authorize(request);
if (!authorized) {
return new NextResponse(null, { status: 401 });
}
// ...
}
With that said, authorization techniques haven’t changed in a long time. I highly recommend learning how they work, but if you don’t feel like reinventing the wheel, there are a ton of libraries and services that handle this all for you.
There are many open-source libraries that handle all of the logic for you, or atleast abstract it to make it easier, such as Auth.js, Lucia, and Better Auth.
There are also many paid services that manage both the logic and data for you at a premium, such as Clerk, Kinde, and WorkOS. These give the added benefit of saving you a lot of time and hassle, but you won’t technically own the data, and you will be depending on the performance and configuration of their services.
Naturally, many of us don’t want to pay money for something we can build ourselves, but I recommend you consider the paid alternatives. Unlike money, you aren’t able to get your time and energy back!
Input Validation
You never want to assume how the user will behave, and you must always be prepared for malice. An API that doesn’t validate or sanitize its inputs might as well be an open door for abuse.
This can be done with your own logic, but libraries like Zod make this a breeze.
export async function POST(request: NextRequest) {
// ...
const body = await request.json();
// Validates the request body with the following rules:
// - body is an object containing a "content" key
// - "content" is a whitespace trimmed string
// - "content" has a minimum length of 1
// - "content" has a maximum length of 200
const { data, success } = await z
.object({
content: z.string().trim().min(1).max(200),
})
.safeParseAsync(body);
if (!success) {
return new NextResponse(null, { status: 400 });
}
// ...
}
Best case scenario, input that isn’t validated may cause an unhandled error—a headache both for the user and the developer, since neither of you won’t know exactly why things broke.
Worst case scenario, input that isn’t validated may lead to unexpected requests to your database or services. This could slow down your server, add a crazy amount to your bill, or manipulate data (ex. SQL injection).
You could prevent all of this from happening with a bit of logic that checks to make sure the input is that of an expected type and format.
Rate Limiting
While some of the problems that arise with a lack of input validation can be minor, a lack of rate limiting is a much more significant issue.
Without rate limiting, something as easy as spam can cause your server to slow down and a crazy large bill. Even if you might be preventing spam on the frontend of your application, it doesn’t take any skill to write a script that sends a massive amount of requests to your server.
Rate limiting is quite a bit more complex to do from scratch though, involving a variety of algorithms and database interactions, so many open-source libraries exist to make enforcing rate limits as easy as possible. A rate limiting library might even be native to your web framework!
Personally, I currently write most of my APIs in TypeScript and have enjoyed using Upstash and their managed Redis database along with their open-source @upstash/ratelimit
library for handling basically all of the logic.
/**
* A rate limiter powered by Upstash Redis, which
* uses a sliding window algorithm preventing more
* than 50 requests in a 24 hour period.
*/
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(50, "24 h"),
});
export async function POST(request: NextRequest) {
// ...
const ip = request.headers.get("x-forwarded-for");
const { success } = await ratelimit.limit(ip ?? "anon");
if (!success) {
return new NextResponse(null, { status: 429 });
}
// ...
}
Again, I highly recommend doing your own research so you understand how rate limiting works under the hood. Similar to authorization, feel free to build your own or use a paid service like Upstash.
Conclusion
That concludes the three primary ways of protecting your database that doesn’t directly involve your own written logic. By implementing all three of these features, you’re already doing better than the majority of APIs, and you’ll be doing yourself a favor!
As a bonus tip: avoid unnecessary, synchronous logic! This is extremely common with developers who aren’t very experienced with SQL databases. If you have consecutive blocking SQL calls that don’t depend on each other, there’s a high probability you can fetch all your data in a single SQL query!