How to Stop Credential Stuffing Attacks

September 15, 2023
September 15, 2023
How to Stop Credential Stuffing Attacks

What is Credential Stuffing?

Credential Stuffing is a method of account takeover where an attacker attempts to gain access to as many customer accounts as possible. Typically performed as an automated (brute force) attack, credential stuffing occurs when random combinations of usernames and passwords are submitted until a valid set is found and login is successful. OWASP notes that “credential stuffing is one of the most common techniques used to take over user accounts.”

Credential Stuffing attacks are effective because of the high probability of password reuse. Some studies suggest that as high as 85% of users will reuse the same credentials for multiple services. As long as password reuse is a common user behavior, Credential Stuffing will always be an attack vector of interest to bad actors.

Attack Stages:

  1. An attacker accesses lists of username and password pairs collected from data breaches.
  2. An attacker uses automated tools to test these username and password pairs on a web application.
  3. When a successful login happens, an attacker uses the credentials for profit. They may use the account to make fraudulent purchases, phishing attempts, online scams, sell private information, or sell login credentials.

Why is Credential Stuffing Prevention Important?

While the success of Credential Stuffing attacks is generally low in the one to three percent range, the negative effect of a successful attack can be tremendous. EY Global research found that a cyber attack can destroy customer trust.

Businesses are increasingly held accountable by the public and regulators to ensure their users’ data security and safety. It has become common for companies to sustain stiff fines and legal action under laws such as GDPR if their security standards, breach communication processes, and best practices lack compliance.

In 2018, the UK's Information Commissioner's Office (ICO) fined Uber £385,000 for "a series of avoidable data security flaws" exposing the data of approximately 2.7 million UK customers. In 2021, the French Data Protection Authority (CNIL) fined a data controller and its data processor €225,000 “for failure to implement adequate security measures to protect customer data against credential stuffing attacks on the website of the data controller.”

How to Prevent Credential Stuffing

Fingerprint Pro provides a unique identifier for every visitor to your website collected behind the scenes anytime someone visits a webpage with our JavaScript fingerprinting agent installed. Since malicious attackers might forge this data, Fingerprint Pro also provides tools for validating these identifiers sent by your front end. As a result, you can protect your users and your business against Credential Stuffing and other account takeover attacks with the approaches described below. At the same time, your legitimate users won’t experience any additional friction.

Since you know your product and business landscape best, it’s up to you to decide how to configure anti-fraud workflows to utilize the visitor ID to catch fraud on your website. Below, we have described some steps and best practices to use as a starting point for your custom solution.

Configuring Fingerprint Pro for Credential Stuffing Prevention

To use Fingerprint Pro effectively to prevent account-related fraud, you should configure logic that utilizes the visitor ID, among other timestamped data, in conjunction with credentials provided by a user. It is crucial to think through the logic used to determine suspicious activity and the challenge actions that should be taken when a visitor is flagged.

Suspicious Activity Logic

We recommend that when a visitor attempts to log in, the visitorId and login credentials are sent to your application server, where they persist in the storage layer. Using this data, you can compare the current visitorId and credential pairing to previous attempts to catch threats.

Here are the recommended logic rules for Credential Stuffing:

First, add the Fingerprint Pro JavaScript agent to your webpage. Alternatively, if your frontend uses modern frameworks such as React.js or Angular, you can use one of our libraries instead.

// Initialize the agent.
const fpPromise = import("https://fpjscdn.net/v3/<your-public-api-key>").then(
  (FingerprintJS) =>
    FingerprintJS.load({
      endpoint: "https://metrics.yourdomain.com",
    })
);

For production deployments, we recommend routing requests to Fingerprint's APIs through your domain using the endpoint parameter. This prevents ad blockers from disrupting identification requests and improves accuracy. We offer a variety of ways to do this, which you can learn more about in our guide on how to protect your JavaScript agent from ad blockers.

When the user attempts to log in, make an identification request and send the requestId along with the user's credentials to your authentication API.

async function onLogIn() {
  // Collect browser signals and request visitor identification
  // from the Fingerprint API. The response contains a `requestId`.
  const { requestId } = await (await fpPromise).get();

  // Send the user’s credentials together with
  // the `requestId` to your authentication API.
  const loginData = {
    username,
    password,
    requestId,
  };

  const response = await fetch("/api/authenticate", {
    method: "POST",
    body: JSON.stringify(loginData),
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
  });
}

The following steps should be performed on the backend using data provided by the Fingerprint Pro Server API. If your backend logic is built on top of Node.js or any other popular server-side framework or language, you can use one of our Fingerprint Server API SDKs. Alternatively, one can use the Webhooks functionality.

Let's check that the requestId is legitimate by hitting the Server API /events endpoint.

const requestId = req.body.requestId;

const fingerprintServerApiUrl = new URL(
  `https://api.fpjs.io/events/${requestId}`
);

const requestOptions = {
  method: "GET",
  headers: {
    "Auth-API-Key": "<secret-api-key>",
  },
};

const fingerprintServerApiResponse = await fetch(
  fingerprintServerApiUrl.href,
  requestOptions
);

// If there's something wrong with the provided data,
// the Server API will return a non-2xx response.
// We consider these data unreliable.
if (
  fingerprintServerApiResponse.status < 200 ||
  fingerprintServerApiResponse.status > 299
) {
  // Handle the error internally and refuse login.
  reportSuspiciousActivity(req);
  persistUnsuccessfulLoginAttempt();
  return getForbiddenReponse();
}

const requestData = await fingerprintServerApiResponse.json();
const visitorData = requestData?.products?.identification?.data;

The Server API response must contain information about this specific identification request. If not, the request might have been tampered with and we don't trust this identification attempt.

// The returned data must have the expected properties.
if (requestData.error || visitorData?.visitorId == undefined) {
  reportSuspiciousActivity(req);
  persistUnsuccessfulLoginAttempt();
  return getForbiddenReponse();
}

An attacker might have acquired a valid request ID via phishing. It's recommended to check the freshness of the identification request to prevent replay attacks.

// Identification event must be a maximum of 3 seconds old.
if (new Date().getTime() - visitorData.timestamp > 3000) {
  persistUnsuccessfulLoginAttempt();
  reportSuspiciousActivity(req);
  return getForbiddenReponse();
}

The Confidence Score reflects the system's degree of certainty that the visitor identifier is correct. If it's lower than a certain threshold, we recommend using an additional way of verification, e.g. 2FA or email.

// The Confidence Score must be of a certain level.
if (visitorData.confidence.score < 0.95) {
  persistUnsuccessfulLoginAttempt();
  reportSuspiciousActivity(req);
  return getForbiddenReponseAndChallenge();
}

We want to check if the authentication request comes from the same IP address as the identification request.

// This is an example of obtaining the client's IP address.
// In most cases, it's a good idea to look for the
// right-most external IP address in the list to prevent spoofing.
if (request.headers["x-forwarded-for"].split(",")[0] !== visitorData.ip) {
  persistUnsuccessfulLoginAttempt();
  reportSuspiciousActivity(req);
  return getForbiddenReponse();
}

Next, check if the authentication request comes from a known origin and if the authentication request's origin corresponds to the origin provided by the Fingerprint Pro Server API. One should also set the Request Filtering settings in the Fingerprint dashboard.

const ourOrigins = ["https://protect-login.yourdomain.com"];

const visitorDataOrigin = new URL(visitorData.url).origin;

// Confirm that the authentication request is from a known origin.
if (
  visitorDataOrigin !== request.headers["origin"] ||
  !ourOrigins.includes(visitorDataOrigin) ||
  !ourOrigins.includes(request.headers["origin"])
) {
  persistUnsuccessfulLoginAttempt();
  reportSuspiciousActivity(req);
  return getForbiddenReponse();
}

We also need to get all unsuccessful attempts during the last 24 hours for this given visitor. We won’t perform the login action if a visitor has reached the threshold. If the visitor ID had five unsuccessful login attempts during the last 24 hours, we do not perform the login. The count of attempts and time window might vary.

// Get the number of unsuccessful login attempts during
// the last 24 hours for the `visitorId`.
const loginAttemptQuery =
  "SELECT COUNT(*) AS count FROM login_attempts WHERE visitor_id = ? AND timestamp > ? AND login_attempt_result NOT IN (?, ?, ?)";
const loginAttemptParams = [
  visitorData.visitorId,
  new Date().getTime() - 24 * 60 * 60 * 1000, // 24 hours.
  "Passed",
  "TooManyLoginAttempts",
  "Challenged",
];
const visitorLoginAttemptCountQueryResult = await db.query(
  loginAttemptQuery,
  loginAttemptParams
);

// If the `visitorId` performed five unsuccessful login
// attempts during the last 24 hours, we do not perform the login.
// The count of attempts and time window might vary.
if (visitorLoginAttemptCountQueryResult.count > 5) {
  persistUnsuccessfulLoginAttempt();
  reportSuspiciousActivity(req);
  return getForbiddenReponse();
}

Finally, we can check if the provided credentials are correct. It’s also a good practice to check if they’ve ever logged in using the provided visitorId before. If not, we recommend using an additional way of verification, e.g. 2FA or email.

// Check the user's credentials and if they're using a known `visitorId`.
if (areCredentialsCorrect(request.body.username, request.body.password)) {
  if (
    isLoggingInFromKnownDevice(
      visitorData.visitorId,
      mockedUser.knownVisitorIds
    )
  ) {
    persistSuccessfulLoginAttempt();
    return getOkReponse();
  } else {
    persistUnsuccessfulLoginAttempt();
    reportSuspiciousActivity(req);
    return getForbiddenReponse();
  }
} else {
  persistUnsuccessfulLoginAttempt();
  reportSuspiciousActivity(req);
  return getForbiddenReponse();
}

function isLoggingInFromKnownDevice(providedVisitorId, knownVisitorIds) {
  return knownVisitorIds.includes(providedVisitorId);
}

Challenge Actions

You can require additional verification or authentication steps to stop fraudsters from further access for suspicious login attempts as defined by your suspicious activity logic.

In all the cases above, we suggest ignoring login attempts, notifying account owners about suspicious activity via email/SMS/phone, or challenging the attempted login with two-factor authentication.

Explore Our Credential Stuffing Prevention Demo

We have built a Credential Stuffing prevention demo to demonstrate the above concepts. Use this demo to see how to use Fingerprint Pro and simple logic rules to protect a login form. If you want to explore the code, check our interactive Stackblitz demo or open-source GitHub repository. If you have any questions, please feel free to reach out to our support team.

FAQ

What is a credential stuffing attack?

Credential stuffing is a cyber attack where stolen account credentials, typically usernames and passwords, are used to gain unauthorized access to user accounts through large-scale automated login requests.

How can businesses protect against credential stuffing?

Implement multi-factor authentication, deploy bot detection, monitor for unusual login attempts, and encourage users to use unique passwords to reduce the risk of successful attacks.

Why is credential stuffing an effective attack?

Many people reuse passwords across multiple accounts, allowing bad actors to automate login attempts and potentially access numerous accounts with a single set of stolen credentials.