Remix Security Checklist
A security checklist for Remix applications: dependencies & updates, module constraints, environment variables, authentication and authorization, cross-site request forgery, security headers, validation, and file uploads.
Essential web security best practices to protect your login pages from common threats like brute force attacks, credential stuffing, SQL injection, and session hijacking.
In the ever-evolving landscape of web security, login pages are prime targets for attackers. As gateways to user accounts and sensitive data, they demand robust protection strategies. This post delves into the primary threats to login pages and then explores how to secure them effectively.
Brute force attacks involve systematically trying various username and password combinations to gain unauthorized access. These attacks can quickly test thousands of combinations, often cracking simple or commonly used passwords.
The way to detect these is by monitoring for a high number of failed login attempts from a single IP address or across multiple accounts within a short time frame. Implementing rate limiting and setting up alerts for such patterns can help identify these attacks early.
Credential stuffing uses stolen username and password combinations from previous breaches to gain access to multiple sites. Bots carry out these attacks at scale, making them harder to detect
The way to detect these is by monitoring for repeated login attempts using different usernames from the same IP address. Implementing bot detection mechanisms and analyzing login patterns can help in identifying and blocking these attacks.
SQL injection involves inserting malicious SQL code into application queries, potentially allowing attackers to bypass authentication entirely.
The way to detect these is by monitoring for unusual or malicious patterns in user input fields, especially those used in database queries. Implement input validation and sanitization, use parameterized queries or prepared statements, and set up logging and alerting mechanisms to detect and respond to suspicious database activity.
Session-based attacks involve hijacking or manipulating valid session tokens. This can include:
The way to detect these is by monitoring for abnormal session behavior, such as session tokens being used from multiple IP addresses or geolocations in a short time. Implement secure session management practices, such as regenerating session tokens upon login, setting appropriate session timeouts, and using secure, HTTP-only cookies. Set up alerts for suspicious session activity to respond quickly to potential attacks.
In addition to implementing preventive measures, detecting and logging potential attacks in real-time is crucial for maintaining robust security. Let’s have a look at some popular frameworks and services, and real-time monitoring techniques.
Implementing effective logging is essential for detecting and responding to attacks. Here are some popular logging frameworks and services in the JavaScript ecosystem:
Using a robust logging framework or service helps ensure that you capture all relevant security events and can respond to them quickly.
Logging is a starting point, but you might also consider real-time monitoring, which allows for immediate detection and response to suspicious activities. Observability tools like Prometheus, Grafana, and Kibana are widely used for real-time monitoring and visualization. These tools can collect custom metrics, such as counting the number of failed login attempts by IP address. A spike in failed logins could indicate an attack, allowing you to block the associated IPs at your firewall.
To implement real-time monitoring, you can use prom-client, a Prometheus client for Node.js. prom-client enables you to define and collect custom metrics, which are crucial for tracking and alerting on suspicious login activities.
Set up alerts for a high number of failed login attempts from a single IP address or across multiple accounts within a short time frame.
const client = require('prom-client');
// Define a counter with a label called "ip"
const failedLoginCounter = new client.Counter({
name: 'failed_login_attempts',
help: 'Number of failed login attempts',
labelNames: ['ip']
});
// Increment the counter where label "ip" is equal
// to the client IP address
failedLoginCounter.inc({ ip: req.ip });
Monitor for repeated login attempts using different usernames from the same IP address to identify credential stuffing attacks.
const client = require('prom-client');
// Define a counter with two labels called "username" and "ip"
const loginAttemptsCounter = new client.Counter({
name: 'login_attempts_total',
help: 'Total number of login attempts',
labelNames: ['username', 'ip']
});
// Increment the counter where label "username" is
// equal to the attempted username for the login
// and the "ip" is equal to the client IP address
loginAttemptsCounter.inc({ username, ip: req.ip });
Brute force attacks use automated tools to try different username and password combinations until access is gained or blocked. These attacks can quickly test thousands of combinations, often cracking simple passwords and potentially overwhelming systems.
Rate limiting is an effective defense against brute force attacks. It restricts the number of login attempts a client can make within a specified time frame, slowing down the attack rate. To implement this, we can use Arcjet, a security middleware designed to protect web applications from various threats. Arcjet provides tools for rate limiting, bot detection, and more, making it easier to secure your application effectively.
Let's implement rate limiting in Next.js, although this theory holds for other Arcjet SDKs. Supposing we have the following login page:
import { useState } from 'react';
export default function Form() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
const response = await fetch('/api/user/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok) {
// Handle successful registration
} else {
// Handle registration error
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>
Username:
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</label>
</div>
<div>
<label>
Password:
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
</div>
<button type="submit">Submit</button>
</form>
);
}
We would rate-limit the API endpoint like this:
import arcjet, { fixedWindow } from "@arcjet/next";
import { NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY,
rules: [
fixedWindow({
mode: "LIVE",
window: "10m", // In a 10 minute window ...
max: 5, // ... allow up to 5 attempts
}),
],
});
export async function POST(req) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
return NextResponse.json(
{ error: "Too many login attempts. Please try again later." },
{ status: 429 }
);
}
// Proceed with login logic
// ...
}
It's important to provide a good user experience by clearly communicating rate limit status to users. You can use the rate limit metadata provided by Arcjet to inform users about their current rate limit status.
Adding this metadata to the response headers provides clients with information such as the maximum number of allowed requests, remaining requests, and time until the rate limit resets. If you’re using Arcjet for rate limiting then you can use the @arcjet/decorate package to automatically add the right headers (check the docs for usage in other frameworks):
import arcjet, { fixedWindow } from "@arcjet/next";
import { setRateLimitHeaders } from "@arcjet/decorate";
import { NextResponse } from "next/server";
const aj = arcjet({
...
});
export async function POST(req: Request) {
const decision = await aj.protect(req);
const headers = new Headers();
setRateLimitHeaders(headers, decision);
if (decision.isDenied()) {
return NextResponse.json(
{ error: "Too many login attempts. Please try again later." },
{ status: 429, headers },
);
}
// Proceed with login logic
// ...
}
Credential stuffing uses stolen username and password combinations across multiple websites to exploit password reuse. These bot-driven attacks are challenging to detect because they use valid credentials and can bypass IP-based rate limiting. Sophisticated bots can also mimic human behavior to evade detection.
Let’s enhance our login security by implementing bot protection, creating a multi-layered defense against credential stuffing and other automated attacks.
import arcjet, { fixedWindow, detectBot } from "@arcjet/next";
import { NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY,
rules: [
fixedWindow({
mode: "LIVE",
window: "10m", // In a 10 minute window ...
max: 5, // ... allow up to 5 attempts
}),
detectBot({
mode: "LIVE",
block: ["AUTOMATED", "LIKELY_AUTOMATED"],
}),
],
});
export async function POST(req) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
return NextResponse.json(
{ error: "Too many login attempts. Please try again later." },
{ status: 429 }
);
} else if (decision.reason.isBot()) {
return NextResponse.json(
{ error: "Automated access denied" },
{ status: 403 }
);
}
}
// Proceed with login logic
// ...
}
This configuration blocks requests identified as automated or likely automated, providing an additional layer of protection against credential stuffing attacks.
While Arcjet provides robust protection, consider implementing these additional measures:
SQL injection attacks target the database query process, potentially allowing attackers to read, modify, or delete database contents. These attacks occur when malicious SQL code is inserted into application queries through incorrectly filtered user input fields, such as the username or password field, to bypass authentication.
Although modern ORMs like Prisma and Drizzle usually provide built-in protections, it’s still important to sanitize and validate all user input, something we highlight in our Next.js security checklist. The worst case outcome would mean an attacker gaining access to read, modify, or delete database contents.
For example:
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
If an attacker inputs admin' –
as the username, the resulting query becomes:
SELECT * FROM users WHERE username = 'admin' --' AND password = 'anything'
This effectively comments out the password check (--
is the comment marker in SQL), potentially granting unauthorized access.
Arcjet Shield includes protection by analyzing requests over time and determining if there is a pattern of consistent SQL injection using a tool such as sqlmap.. Let's integrate it into our login protection:
import arcjet, { fixedWindow, detectBot, shield } from "@arcjet/next";
import { NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY,
rules: [
fixedWindow({
mode: "LIVE",
window: "10m", // In a 10 minute window ...
max: 5, // ... allow up to 5 attempts
}),
detectBot({
mode: "LIVE",
block: ["AUTOMATED", "LIKELY_AUTOMATED"],
}),
shield({
mode: "LIVE",
}),
],
});
export async function POST(req) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
return NextResponse.json(
{ error: "Too many login attempts. Please try again later." },
{ status: 429 }
);
} else if (decision.reason.isBot()) {
return NextResponse.json(
{ error: "Automated access denied" },
{ status: 403 }
);
} else if (decision.reason.isShield()) {
return NextResponse.json(
{ error: "Suspicious activity detected" },
{ status: 403 }
);
}
}
// Proceed with login logic
// ...
}
While Arcjet Shield provides a strong first line of defense, implementing additional protective measures is crucial:
// Prepare the SQL statement
const query = `
SELECT * FROM users
WHERE username = $1
AND password_hash = crypt($2, password_hash)
`;
// Pass the parameters in at the time of statement execution
const res = await client.query(query, [username, password]);
// Handle results
} catch (err) {
// Handle error
} finally {
client.release();
}
import { z } from "zod";
const loginSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(8),
});
const { username, password } = loginSchema.parse(await req.json());
-- Allow app_user to connect to the database
GRANT CONNECT ON DATABASE myapp TO app_user;
-- Allow app_user to use the public schema
GRANT USAGE ON SCHEMA public TO app_user;
-- Grant SELECT, INSERT, UPDATE permissions on the users table
GRANT SELECT, INSERT, UPDATE ON TABLE users TO app_user;
-- Enabled row level security on the users table
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Create a policy that restricts updates to a single user and
-- only if the primary key matches the current user ID
CREATE POLICY update_single_user ON users
FOR UPDATE
USING (true)
WITH CHECK (id = current_setting('app.user_id', true)::integer);
-- Create a Postgres function that allows us to set the current user ID
CREATE OR REPLACE FUNCTION set_app_user_id(userid int) RETURNS void AS $$
BEGIN
PERFORM set_config('app.user_id', userid::text, false);
END;
$$ LANGUAGE plpgsql;
Now, after a user logs in, we can tell Postgres what the current user’s ID is:
// Get the users that match these credentials
const res = await client.query(query, [username, password]);
// If only one result
if (res.rows.length === 1) {
// Get the user
const user = res.rows[0];
// Set Postgres application user ID context for row-level security
await client.query('SELECT set_app_user_id($1)', [user.id]);
// Proceed with the authenticated user
return user;
} else {
throw new Error('Invalid credentials');
}
} catch (err) {
// Handle error
} finally {
client.release();
}
try {
// Database operation
} catch (error) {
// The next line could share too much information if publicly returned...
console.error("Database error:", error);
// Instead, only return a general error to the user
return NextResponse.json({ error: "An error occurred" }, { status: 500 });
}
After implementing protections against various login attacks, it's crucial to ensure that authenticated sessions remain secure. Improper session management can lead to vulnerabilities that allow attackers to hijack user sessions or impersonate legitimate users.
As outlined above, session-based attacks typically involve session hijacking, session fixation, or cross-site scripting attacks.
We said that session hijacking can occur by guessing the session ID, and in reality, this would only happen if you’re using a weak algorithm for ID generation. Using a secure random number generator such as randomBytes() from Node’s built-in crypto package makes guessing this highly unlikely.
Man-in-the-middle attacks and network sniffing tools might be possible avenues for intercepting session IDs in transit, but the use of HTTPS will prevent this.
This leaves cross-site scripting as an attack vector, in which malicious scripts or browser add-ons could get access to your cookies and provide them to the attacker, or force a user’s connection to your web application to use a predefined session ID that the attacker can then be ready to use.
As you have Arcjet Shield in place already, you’re already protected against the likes of SQL injection and cross-site scripting attacks, but there’s more you can do to secure your users’ sessions.
Let's implement some best practices for secure session management:
function serializeSession(session) {
// Define cookie data
const data = {
session: encodeURIComponent(session),
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 3600,
path: "/",
};
// Return as serlialized string
return Object.entries(data)
.map(([key, value]) => `${key}=${value}`)
.join(";");
}
function rotateSession(oldSession) {
const newSession = createSession();
// Transfer necessary data from oldSession to newSession
// Invalidate oldSession
return newSession;
}
const SESSION_IDLE_TIMEOUT = 15 * 60 * 1000; // 15 minutes
const SESSION_ABSOLUTE_TIMEOUT = 2 * 60 * 60 * 1000; // 2 hours
function createSession() {
const sessionId = 'generate-unique-session-id';
const session = {
sessionId,
createdAt: Date.now(),
lastActivity: Date.now(),
};
}
function serializeSession(session) {
// Define cookie data
const data = {
session: encodeURIComponent(session),
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
// Set absolute timeout
maxAge: SESSION_ABSOLUTE_TIMEOUT / 1000,
path: "/",
};
// Return as serlialized string
return Object.entries(data)
.map(([key, value]) => `${key}=${value}`)
.join(";");
}
export async function POST(req) {
const headers = new Headers();
// Get session from the request (e.g., from cookies or headers)
let session = getSession(req);
if (!session) {
// Create a new session and add to headers
headers.set('Set-Cookie', serializeSession(createSession()));
} else {
// Check if the session has expired
if (
((Date.now() - session.lastActivity) > SESSION_IDLE_TIMEOUT) ||
((Date.now() - session.createdAt) > SESSION_ABSOLUTE_TIMEOUT)
) {
return NextResponse.json(
{ error: "Session Expired" },
{ status: 401, headers }
);
}
// Update session activity and add updated session cookie to headers
session.lastActivity = Date.now();
headers.set('Set-Cookie', serializeSession(session));
}
// Proceed with login logic
return NextResponse.json(
{ message: "Login successful", session },
{ status: 200, headers }
);
}
Cross-Site Request Forgery (CSRF) attacks trick users into performing actions they didn't intend to perform. Protecting against CSRF attacks involves using anti-CSRF tokens for critical actions, ensuring that only authorized requests can be processed. Here’s how to implement CSRF protection in Next.js.
First, create an API endpoint that generates a CSRF token per session and returns that each time it’s called:
import { randomBytes } from 'crypto';
import { getSession } from 'next-session';
function generateCSRFToken() {
return randomBytes(32).toString('hex');
}
export default async function handler(req, res) {
const session = await getSession(req, res);
if (!session.csrfToken) {
session.csrfToken = generateCSRFToken();
}
res.status(200).json({ csrfToken: session.csrfToken });
}
We then call this endpoint in our login page to include the CSRF token in the form:
import { useState } from 'react';
export default function Form() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [csrfToken, setCsrfToken] = useState('');
useEffect(() => {
fetch('/api/getCsrfToken')
.then(response => response.json())
.then(data => setCsrfToken(data.csrfToken));
}, []);
const handleSubmit = async (e) => {
// ... handler as before
};
return (
<form onSubmit={handleSubmit}>
{/* Username and password form fields */}
<input type="hidden" name="csrfToken" value={csrfToken} />
<button type="submit" disabled={csrfToken === ""}>Submit</button>
</form>
);
}
On the server side, validate the token before processing the request.
import { getSession } from 'next-session';
export async function POST(req, res) {
const session = await getSession(req, res);
const { csrfToken, ...otherData } = req.body;
// Validate CSRF token
if (csrfToken !== session.csrfToken) {
return res.status(403).json({ error: "Invalid CSRF token" });
}
// Proceed with rate-limiting and login
// ...
}
A strong Content Security Policy (CSP) helps mitigate the risk of Cross-Site Scripting (XSS) attacks by specifying which sources of content are allowed to be loaded and executed.
Set the CSP header in your server response to restrict the allowed content sources:
const nextSafe = require("next-safe");
const isDev = process.env.NODE_ENV !== "production";
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
poweredByHeader: false,
async headers() {
return [
{
source: "/:path*",
headers: nextSafe({
isDev,
contentSecurityPolicy: {
"default-src": ["'self'"],
"script-src": [
"'self'",
"'unsafe-inline'",
"https://plausible.io/js/script.js", // Analytics
],
"img-src": ["'self'", "blob:", "data: https://www.gravatar.com"],
"style-src": ["'self'", "'unsafe-inline'"],
"connect-src": [
"'self'",
"https://plausible.io", // Analytics
],
// prefetch-src is deprecated
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/prefetch-src
"prefetch-src": false,
},
}),
},
];
},
};
module.exports = nextConfig;
For more detail on this, check out the “Setting security headers in Next.js” section of our article, Next.js security checklist.
Binding sessions to the original IP address helps ensure that the session cannot be hijacked and used from a different IP address. This adds an extra layer of security for highly sensitive operations.
This can be implemented by storing the IP address in the session and checking it on subsequent requests:
function createSession() {
const sessionId = 'generate-unique-session-id';
const session = {
sessionId,
ip: getUserIP(),
};
}
export async function POST(req) {
const headers = new Headers();
let session = getSession(req);
if (!session) {
// Create a new session and add to headers
headers.set('Set-Cookie', serializeSession(createSession()));
} else {
// Check IP Binding
if (session.ip !== getUserIP()) {
// This session seems to be used from more than one place
// Require reauthentication...
}
}
// Proceed with login logic
// ...
}
Securing login pages is a critical aspect of web security, given their role as gateways to user accounts and sensitive data. As we've explored, the threats to these pages are varied and evolving, from brute force and credential stuffing attacks to SQL injection and session hijacking. Implementing robust protection strategies is essential to safeguard your users and their information.
Security is an ongoing journey, not a one-time setup. As attackers develop more sophisticated methods, your security measures must evolve to counter these threats. Regularly review and update your security practices, invest in training for your team, and stay vigilant.
Remember, the goal is not just to protect your users' data but to build trust and ensure a safe and secure user experience. By prioritizing security in your development process, you contribute to a safer internet for everyone.
Thank you for taking the time to read this guide. Implement these strategies, and you’ll be well on your way to fortifying your login pages against the myriad of threats they face.
A security checklist for Remix applications: dependencies & updates, module constraints, environment variables, authentication and authorization, cross-site request forgery, security headers, validation, and file uploads.
Idiomatic code generation for Go using the Wasm Component Model. Compiling for different languages always has tradeoffs, which is why using standards helps everyone.
Nosecone is an open source library to set security headers like Content Security Policy (CSP) and HTTP Strict Transport Security (HSTS) on Next.js, SvelteKit, and other JavaScript frameworks using Bun, Deno, or Node.js. Security headers as code.
Get the full posts by email every week.