Application & framework security

Web app security checklist

After you follow the framework guides, check whether the live app can tell bots from humans, rate-limit the costly operation, enforce product rules on automatable flows, reject bad requests before they cost money, and log decisions you can alert on. Includes a concrete v1 example for Next.js, Remix, and Nuxt.

6 min read
In short: After you follow the framework guides, check whether the live app can tell bots from humans, rate-limit the costly operation, enforce product rules on automatable flows, reject bad requests before they cost money, and log decisions you can alert on. Includes a concrete v1 example for Next.js, Remix, and Nuxt.

What is the web app security checklist?

Framework guides tell you how to use Next.js, Remix, or Nuxt safely: where secrets live, which functions run on the server, how to set cookies. This checklist starts after that work. It asks whether the live app can survive automated traffic, unexpected API use, and business-logic abuse.

Use the framework guides first:

Then work through these six questions. Each one is a control you can verify in production, not a slogan.

  • Can you tell human traffic from automated traffic on each sensitive route?
  • Do rate limits match how the route is used (user, token, or IP; anonymous vs authenticated)?
  • Which business flows would hurt you if they were automated with valid requests?
  • Are invalid or oversized requests rejected before they reach expensive code?
  • Can you see when abuse started, which route was hit, and whether a control fired?
  • Do the controls run where the request is handled, including serverless and edge?

Why do framework security guides stop here?

Framework docs can be generic. They cover headers, auth patterns, and configuration. They cannot know that your trial signup is expensive, that your search endpoint dumps the catalog, or that anonymous users share a NAT.

As more logic moves into Route Handlers, loaders, actions, and server routes, that gap gets wider. Abuse looks like a valid user doing a valid thing too often, or from the wrong client. The questions below are the ones teams usually ask after the first incident.

How do you detect automated traffic?

Most apps assume a browser. Bots submit forms, stuff credentials, enumerate IDs, and scrape APIs. IP denylists fail when attackers rotate addresses and when real users share a network.

You should be able to answer:

  • What share of traffic on login, signup, and search is automated?
  • Which routes are hit by bots?
  • Can you deny abusive automation without blocking a search crawler you want?

Bot detection belongs next to the handler, where you know the route and whether a session exists. A public marketing page can allow CATEGORY:SEARCH_ENGINE. A login action should allow none.

Next.js Route Handler:

import arcjet, { detectBot, shield } from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [shield({ mode: "LIVE" }), detectBot({ mode: "LIVE", allow: [] })],
});
export async function POST(req: Request) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
}

Remix action (Remix v2; React Router 7 uses @arcjet/react-router):

import arcjet, { detectBot, shield } from "@arcjet/remix";
import type { ActionFunctionArgs } from "@remix-run/node";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [shield({ mode: "LIVE" }), detectBot({ mode: "LIVE", allow: [] })],
});
export async function action(args: ActionFunctionArgs) {
const decision = await aj.protect(args);
if (decision.isDenied()) {
throw new Response("Forbidden", { status: 403 });
}
}

Nuxt server route:

import arcjetNuxt, { detectBot, shield } from "#arcjet";
const arcjet = arcjetNuxt({
rules: [shield({ mode: "LIVE" }), detectBot({ mode: "LIVE", allow: [] })],
});
export default defineEventHandler(async (event) => {
const decision = await arcjet.protect(event);
if (decision.isDenied()) {
throw createError({ statusCode: 403, statusMessage: "Forbidden" });
}
});

How should you rate limit APIs?

A page view is several API calls. A generic per-IP limit either blocks an office or ignores a distributed bot. Ask:

  • Is the limit keyed on user, API token, or IP?
  • Do anonymous and authenticated callers have different ceilings?
  • Can one user trigger an expensive export or AI call in a tight loop?

Abuse is often shape, not volume. Five valid checkout attempts can cost more than a thousand junk GETs. Put a tighter limit on the costly operation. The rate limiting guide compares fixed window, sliding window, and token bucket.

Next.js, keyed on a user characteristic once you have a session:

import arcjet, { slidingWindow } from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["userId"],
rules: [slidingWindow({ mode: "LIVE", interval: 60, max: 10 })],
});
export async function POST(req: Request) {
const userId = "user-123"; // from your session
const decision = await aj.protect(req, { userId });
if (decision.isDenied()) {
return Response.json({ error: "Too many requests" }, { status: 429 });
}
}

Remix loader, IP-keyed before login:

import arcjet, { fixedWindow } from "@arcjet/remix";
import type { LoaderFunctionArgs } from "@remix-run/node";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [fixedWindow({ mode: "LIVE", window: "1m", max: 30 })],
});
export async function loader(args: LoaderFunctionArgs) {
const decision = await aj.protect(args);
if (decision.isDenied()) {
throw new Response("Too many requests", { status: 429 });
}
return null;
}

Nuxt, token bucket for a variable-cost route:

import arcjetNuxt, { tokenBucket } from "#arcjet";
const arcjet = arcjetNuxt({
rules: [
tokenBucket({
mode: "LIVE",
refillRate: 5,
interval: 10,
capacity: 10,
}),
],
});
export default defineEventHandler(async (event) => {
const decision = await arcjet.protect(event, { requested: 5 });
if (decision.isDenied()) {
throw createError({ statusCode: 429, statusMessage: "Too Many Requests" });
}
});

How do you protect application logic from misuse?

Many attacks never fail validation. They automate a flow you designed for a human: trial signups, inventory holds, password resets, preview renders.

List the actions that become expensive or harmful when automated. Put a product rule next to the code that performs them: one trial per verified email, one hold per account, a cooldown on password reset. Schema validation cannot express "this user already reserved the last seat."

Login and signup are the usual first targets. Apply the tighter controls in secure login pages.

How do you stop bad requests before they cost money?

A 400 after you have parsed a 20 MB body, called the ORM, and invoked a model still costs you. Reject early:

  • Enforce body and file size limits at the framework or host.
  • Validate the schema before any database or provider call.
  • Time out and cancel downstream work when the client disconnects.
  • Fail closed on login, payment, and export if the security dependency is down; fail open only where availability matters more than abuse.

That last one needs code, because the default is fail-open and it is easy to miss. When Arcjet cannot reach the service, the decision comes back as an error, and isDenied() is false for it, so a handler that only branches on isDenied() lets the request through. Every other sample on this page does exactly that, which is the right default for a public read. On the routes above, add the second branch:

export async function POST(req: Request) {
const decision = await aj.protect(req);
if (decision.isErrored()) {
// Availability of the checkout matters less than charging the wrong card.
console.error("Arcjet decision failed", decision.reason.message);
return Response.json({ error: "Service unavailable" }, { status: 503 });
}
if (decision.isDenied()) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
// Charge the card.
}

Shield-style request analysis helps when the payload looks like injection or XSS. It does not replace a size limit or a parameterized query.

What should you monitor for abuse?

A control you cannot see will drift. Log the route, a request or decision ID, the conclusion, and the reason code. Do not log passwords, tokens, or raw bodies.

Alert on:

  • A step change in denied requests on one route
  • Failed logins across many accounts from one client
  • A rise in 429s that does not match a release
  • Exports or AI calls far above the daily baseline

Give each alert an owner and a response: tighten a rule, rotate a credential, or take one route offline without taking the site down.

How do you keep security in the request path?

If a tool does not run on your serverless or Node host, you have a gap. If it takes a week to configure, it ships after the feature. Prefer a library you call in the handler so the rule sits next to the code it protects.

That is true for Next.js, Remix, Nuxt, and NestJS. The SDK package name changes; the habit does not: decide before you do the work, branch on deny, and keep the rule in source control.

Frequently asked questions

Is a framework security checklist enough for production?

No. Framework guides cover headers, secrets, and server-only boundaries. Production abuse is automated traffic, unexpected API use, and valid requests that violate a product assumption.

Should every route use the same rate limit?

No. Key limits on user, token, or IP as appropriate. Give anonymous and authenticated callers different ceilings. Put a tighter limit on exports, login, and other costly operations.

Can I rely on IP blocking to stop bots?

No. Attackers rotate addresses and real users share NATs. Detect automation next to the handler, and allow search crawlers only on public pages.

Which frameworks does this hub cover?

Next.js, Remix, and Nuxt each have a concrete example. NestJS has its own guide. Use the framework checklists for setup, then this hub for live-traffic abuse.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.