Application & framework security

How to secure login pages

Plan for four threats: brute force, credential stuffing, SQL injection, and session theft. Detect with failed-login metrics. Defend with a 5-per-10-minute fixed window, detectBot({ allow: [] }), parameterized queries, and rotated HttpOnly cookies. Examples are Arcjet JS SDK v1.

7 min read
In short: Plan for four threats: brute force, credential stuffing, SQL injection, and session theft. Detect with failed-login metrics. Defend with a 5-per-10-minute fixed window, detectBot({ allow: [] }), parameterized queries, and rotated HttpOnly cookies. Examples are Arcjet JS SDK v1.

Why do login pages need their own controls?

A login page is an unauthenticated endpoint that, on success, issues a session. Attackers try passwords, replay leaked credentials, inject SQL into the username field, and steal or fix sessions. The same HTML form is the target for all four.

This article applies to any server-rendered or API-backed login. Examples use Next.js App Router and @arcjet/next v1. The same rule shapes work in Remix, Nuxt, and NestJS. For framework hygiene around the form, see the Next.js security checklist and the web app security checklist.

Which login threats should you plan for?

ThreatWhat you seeDetectionDefense
Brute force

Many password guesses for one account, or many accounts from one client

Failed-login count per IP and per username in a short window

Fixed-window rate limit, lockout or cooldown, strong password policy, MFA

Credential stuffing

Valid-looking logins using emails from a breach dump, often from bots

Many usernames from one client; automation signals; success on reused passwords

Bot denial on the login route, per-IP and per-account limits, MFA, breach-password check

SQL injectionQuotes, comments, or tautologies in username or password fieldsMalformed input; unusual query errors; scanner user agents

Parameterized queries, schema validation, least-privilege DB role, request shielding

Session attacksStolen or predicted session ID; fixation; XSS reading the cookieSame session from distant IPs; session used after logout

HttpOnly Secure SameSite cookies, rotation on login, idle and absolute timeouts, CSRF tokens, CSP

How do you detect login attacks?

Log every login attempt with a timestamp, username (or a hash of it), client IP, success or failure, and a correlation ID. Do not log the password or the full cookie.

Pino, Winston, and Bunyan all emit JSON you can ship to Grafana, Datadog, or the Elastic Stack. For live alerts, export a counter to Prometheus with prom-client:

import { Counter } from "prom-client";
const failedLogins = new Counter({
name: "failed_login_attempts",
help: "Failed login attempts",
labelNames: ["ip"],
});
const loginAttempts = new Counter({
name: "login_attempts_total",
help: "Login attempts by username and IP",
labelNames: ["username", "ip"],
});
failedLogins.inc({ ip });
loginAttempts.inc({ username, ip });

Alert when one IP fails many times, when one IP tries many usernames, or when failures rise across the fleet. That signal is how you distinguish brute force from stuffing. It is not a substitute for blocking; it tells you when to tighten a limit or revoke sessions.

How do you rate limit login attempts?

A fixed window of 5 attempts per 10 minutes is a reasonable starting point for a password form. Key on IP before authentication. After a successful identification of the account (even on failure), also key on the username so a botnet cannot spray one account from many addresses without a second limit.

import arcjet, { fixedWindow } from "@arcjet/next";
import { setRateLimitHeaders } from "@arcjet/decorate";
import { NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
fixedWindow({
mode: "LIVE",
window: "10m",
max: 5,
}),
],
});
export async function POST(req: Request) {
const decision = await aj.protect(req);
const headers = new Headers();
setRateLimitHeaders(headers, decision);
if (decision.isDenied() && decision.reason.isRateLimit()) {
return NextResponse.json(
{ error: "Too many login attempts. Try again later." },
{ status: 429, headers },
);
}
// validate, then authenticate
}

Return 429 with Retry-After or draft RateLimit headers so a legitimate user knows to wait. Do not tell the client whether the username exists.

Add MFA after a successful password check. Increase delay or drop max after repeated failures from the same IP. Enforce a password policy (length, no breached passwords). OWASP documents the baseline.

How do you stop credential stuffing?

Stuffing uses real passwords from other breaches. Rate limits alone fail when the bot stays under the ceiling and rotates IPs. Deny automated clients on the login route.

import arcjet, { detectBot, fixedWindow } from "@arcjet/next";
import { NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
fixedWindow({
mode: "LIVE",
window: "10m",
max: 5,
}),
detectBot({
mode: "LIVE",
allow: [],
}),
],
});
export async function POST(req: Request) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
return NextResponse.json(
{ error: "Too many login attempts. Try again later." },
{ status: 429 },
);
}
if (decision.reason.isBot()) {
return NextResponse.json(
{ error: "Automated access denied" },
{ status: 403 },
);
}
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// validate, then authenticate
}

detectBot({ allow: [] }) is the v1 allow-list form. It denies every detected bot. Do not use the old block: ["AUTOMATED"] option; that API is gone.

Check passwords against a breach corpus (Have I Been Pwned's k-anonymity API or a local list). Require MFA for privileged roles. Notify the user on a login from a new device.

How do you prevent SQL injection on login?

Never build a query by concatenating the username or password. This is the classic bypass:

SELECT * FROM users WHERE username = 'admin' --' AND password = 'x'

Use a parameterized query or an ORM that always parameterizes:

const res = await client.query(
`SELECT id, password_hash FROM users WHERE username = $1`,
[username],
);

Compare the password with a slow hash (argon2id or scrypt), not with SQL crypt in a string you control from the client. Validate the body first:

import { z } from "zod";
const loginSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(8).max(128),
});
const parsed = loginSchema.safeParse(await req.json());
if (!parsed.success) {
// A malformed body is a 400, not an unhandled throw and a 500.
return Response.json({ error: "Invalid credentials" }, { status: 400 });
}
const { username, password } = parsed.data;

Grant the app role SELECT (and whatever else it needs) on users, not SUPERUSER. Enable row-level security if the same role later reads tenant data. Return a generic "Invalid credentials" to the client. Log the real database error only on the server.

Shield analyzes requests for injection and XSS patterns. Add it beside the rate limit and bot rules:

import arcjet, { detectBot, fixedWindow, shield } from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
fixedWindow({ mode: "LIVE", window: "10m", max: 5 }),
detectBot({ mode: "LIVE", allow: [] }),
shield({ mode: "LIVE" }),
],
});

Shield is a pattern filter. It does not replace parameterized queries.

How do you keep login sessions from being stolen?

After a successful password check, issue a new session ID. Do not reuse an anonymous ID (that is session fixation). Generate the ID with crypto.randomBytes or your framework's session helper, not with a sequential integer.

Set the cookie as HttpOnly, Secure in production, SameSite=strict or Lax (Lax if you need top-level GET navigation to send it), and a bounded Max-Age. Implement an idle timeout (for example 15 minutes without activity) and an absolute timeout (for example 2 hours).

Rotate the session again after a privilege change (password change, MFA enrollment, role upgrade). Invalidate the old ID.

Store a CSRF token in the server session and require it on POST. For Next.js App Router, prefer the framework's origin check plus a double-submit or session token; do not copy a Pages Router getSession snippet that no longer matches your stack.

Set a Content Security Policy that disallows inline scripts you do not need. XSS is the usual way to read a cookie that is not HttpOnly or to fire requests as the user. The Next.js security checklist covers header setup.

IP binding (reject the session if the client IP changes) helps on high-risk accounts and hurts users on mobile networks. Use it for admin sessions, or treat an IP change as a step-up MFA prompt rather than a hard logout for ordinary users.

What does a complete login handler look like?

Order of operations:

  1. Screen the request (rate limit, bots, shield).
  2. Parse and validate the body.
  3. Authenticate with a parameterized lookup and a password hash.
  4. On success, rotate the session, set a tight cookie, and issue a CSRF token.
  5. On failure, increment metrics, return a generic error, and do not leak whether the user exists.

Keep the same order in Remix actions, Nuxt server routes, and NestJS controllers. The web app security checklist extends these controls to signup, reset, and other costly anonymous flows.

Frequently asked questions

What rate limit should a login form use?

Start with fixedWindow({ mode: "LIVE", window: "10m", max: 5 }) keyed on IP. Add a per-username limit so a botnet cannot spray one account from many addresses.

How do you configure bot denial on login in Arcjet v1?

Use detectBot({ mode: "LIVE", allow: [] }). That denies every detected bot. The old block: ["AUTOMATED"] option is gone.

Is rate limiting enough against credential stuffing?

No. Stuffing stays under ceilings and rotates IPs. Combine bot denial, MFA, and a check against breached passwords.

How do you stop SQL injection on a login form?

Parameterize every query, validate the body with a schema, use a least-privilege database role, and return a generic error. Shield is an extra filter, not a substitute.

What cookie flags should a session use?

HttpOnly, Secure in production, SameSite=strict or Lax, and a bounded Max-Age. Rotate the session ID on login and after privilege changes.

Application security in your code

Protect your application with Arcjet

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