JS
Updated
14 min read

Security Tips for Protecting Web Application Login Pages

Essential web security best practices to protect your login pages from common threats like brute force attacks, credential stuffing, SQL injection, and session hijacking.

Security Tips for Protecting Web Application Login Pages

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.

Key Threats to Login Pages

Brute Force Attacks

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

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

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

Session-based attacks involve hijacking or manipulating valid session tokens. This can include:

  • Session Hijacking - Stealing or guessing a valid session token to impersonate a user.
  • Session Fixation - Forcing a user to use a known session ID, which the attacker then uses.
  • Cross-Site Scripting (XSS) - Injecting malicious scripts to steal session cookies.

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.

Attack Detection Strategies

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:

  • Pino: A fast and lightweight logging library for Node.js. This is the one we use for structured logging to JSON in Next.js.
  • Winston: A versatile logging library for Node.js applications.
  • Bunyan: A JSON logging library for Node.js.
  • Loggly: A cloud-based log management service.
  • Splunk: A platform for searching, monitoring, and analyzing machine-generated big data.
  • Elastic Stack (ELK): A powerful toolset for searching, analyzing, and visualizing log data in real-time.

Using a robust logging framework or service helps ensure that you capture all relevant security events and can respond to them quickly.

Real-time Monitoring Techniques

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.

Brute Force Detection

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 });

Credential Stuffing Detection

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 });

Securing Your Login Pages

Defending Against Brute Force Attacks with Rate Limiting

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
  // ...
}

Clear Communication

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
  // ...
}

Handling Credential Stuffing and Bot Attacks

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.

Enhancing Security with Additional Measures

While Arcjet provides robust protection, consider implementing these additional measures:

  1. Multi-Factor Authentication (MFA): Require a second form of verification after successful password authentication. Check out Time2fa if you want to add MFA to a Node.js application.
  2. Progressive Delays: Increase the delay between allowed login attempts after failures, discouraging automated attacks while allowing legitimate users to correct mistakes. This can be implemented by using Prometheus’ detection of invalid logins from the same IP as a multiplier for the Arcjet rate-limit configuration. For example, you might let people make 5 attempts every 10 minutes, but if they have 15 or more incorrect logins, you could drop that to 1 attempt every 10 minutes.
  3. Secure Password Policies: Enforce strong password requirements and consider implementing a password strength meter. The Open Web Application Security Project (OWASP) has an open-source package to help with this called owasp-password-strength-test.

Protecting Against SQL Injection Attacks

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.

Implementing Arcjet Shield for SQL Injection Protection

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
  // ...
}

Additional SQL Injection Prevention Techniques

While Arcjet Shield provides a strong first line of defense, implementing additional protective measures is crucial:

  1. Use Parameterized Queries: Instead of concatenating strings, use parameterized queries or prepared statements. For example, if we’re connecting to a Postgres database, we might write this:
// 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();
}
  1. Input Validation and Sanitization: Validating and sanitizing all user inputs before using them in queries will limit the range of movement an attacker has, reducing the likelihood of SQL injection attacks working:
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());
  1. Least Privilege Principle: Ensure your database user has only the necessary permissions for the operations it needs to perform. For example, in Postgres, with a database myapp, we might limit the system user in this way:
-- 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();
}
  1. Error Handling: Avoid exposing detailed error messages that could reveal database structure:
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 });
}

Secure Session Management and Protection Against Session-Based Attacks

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.

Implementing Secure Session Management

Let's implement some best practices for secure session management:

  1. Use Secure, HTTP-Only Cookies:
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(";");
}
  1. Implement Session Rotation: After a successful login or privilege level change, generate a new session ID. This makes sure that any previously hijacked session doesn’t benefit from the additional privileges granted after logging in.
function rotateSession(oldSession) {
  const newSession = createSession();
  // Transfer necessary data from oldSession to newSession
  // Invalidate oldSession
  return newSession;
}
  1. Set Proper Session Timeout: Implement both idle and absolute timeouts for sessions.
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 }
  );
}

Implement CSRF Protection

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
  // ...
}

Use Strict Content Security Policy (CSP)

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.

Implement IP Binding for Critical Sessions

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
  // ...
}

Final Thoughts

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.

Related articles

Remix Security Checklist
Remix
12 min read

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.

Subscribe by email

Get the full posts by email every week.