Skip to content

FingerprintJS Pro Demo: preventing credential stuffing attacks

License

Notifications You must be signed in to change notification settings

molefrog/fpjs-login-demo

Repository files navigation

Preventing credential stuffing attacks

In this demo, we're going to explore how FingerpringJS Pro can easily help you to protect your website or web service against automated credential stuffing attacks. To do so, we're going to build a simple login form experience with a server-side request throttling based on the current visitorId.

The application we're going to build

Why is this important? Almost every app on the Web starts with a login form. Although developers have being doing this for years, it's always hard to build bulletproof and secure login and session management in your apps. User credentials are being leaked all the time, mature businesses are not the exception.

What you'll learn in this tutorial:

  • How to build a simple login form with Remix and React
  • How to implement a basic login attempts logging
  • How to use Fingerprint's React integration to get the current browser identifier
  • How to implement request throttling based on that identifier

1. Getting Started

Let's bootstrap our app!

We'll use Remix – a React framework that is currently getting a nice adoption in the web community. Remix uses a slightly different approach from other isomorphic React toolkits utilizing a concept of loaders and actions. It might sound a bit complicated, but please bear with me, it's going to be fun!

# let's bootstrap a basic Remix app
npx create-remix@latest login-app

👉 Commit

2. Shaping Up Our Login Form

It's nice to start with some fake implementation first, so we can focus on the real logic later. While it's always fun to write CSS, for the prototype, I recommend using something that doesn't require much configuration (like classeless CSS framework new.css).

👉 Commit

Remix provides a handy component Form for form submission and useTransition hook that we'll use for a loader.

import { useTransition, Form } from "@remix-run/react";

const transition = useTransition();

// Transitions in Remix represent the navigation state
// That's how we know if the form is being sumbitted or not
const isLoading = transition.state !== "idle";

return (
  <Form method="post" action="/login">
    {/* form fields and loading indicator */}
  </Form>
)

Next, let's implement fake form submission: it will only accept one hardcoded email and return an error otherwise.

export const action: ActionFunction = async ({ request }) => {
  const data = await request.formData();

  const username = data.get("email") as string;

  // login successful!
  if (username === "[email protected]") {
    return redirect("/account");
  }

  return json<FormResponse>({
    errorMessage: "Bad luck, please try different login or password!"
  });
};

👉 Commit

3. Wiring It Up

Now it's time to implement real authentication that will rely on email and password stored in a database. To do that, we'll create a SQLite database and provision it with some users. Let's initialize a new database file and create a users table. Tip: to speed things up, use a GUI client like SQLPro.

CREATE TABLE "users" (
  "id" integer PRIMARY KEY NOT NULL,
  "email" char(128) NOT NULL,
  "password" char(128) NOT NULL,
  "username" char(128) NOT NULL
)

⚠️ Warning: while in this particular example we use passwords as-is, you should never ever store passwords in plain text! Always rely on hashed and salted values instead. Read this for more information.

Now when a request comes in, we just need to find if that email-password pair matches any record in our database:

// routes/index.tsx
import { findUserByCredentials } from "../db/queries.server";

// loader
export const action: ActionFunction = async ({ request }) => {
  // ... extract email and password from FormData

  const user = await findUserByCredentials(email, password);

  if (!user) {
    // no user found, respond with an error message
    return json<FormResponse>({
      errorMessage: "Bad luck, please try different login or password!"
    });
  }

  // respond with a redirect
}

You might notice that the file we're importing findUserByCredentials from is called queries.server.ts. That is a special naming convention Remix used to exclude server-side code from the bundle when it can't automatically prune it (the library sqlite we're using can only be used within Node).

👉 Commit

4. Introducing Request Throttling

Finally, let's ensure that our login form is protected against malicious bots trying to brute force credentials. We'll log all unsuccessful login attempts and then block the requests that happen suspiciously too often. Let's create a new table, login_attempts:

CREATE TABLE "login_attempts" (
  "id" integer PRIMARY KEY NOT NULL,
  "visitor_id" char(128),
  "created_at" timestamp(128) NOT NULL,
  "email" char(128) NOT NULL
)

We'll need some way to identify requests: obviously, we can't entirely rely on the email as well as on the IP (since there might be users who are sitting behind a NAT) – and that's where visitor_id column comes in. We will soon see how to obtain that identifier but for now let's write a logging function.

// call this method when authentication wasn't successful
export async function logFailedLoginAttempt(
  email: string,
  visitorId: string
): Promise<void> {
  const db = await openDB(); // open and init SQLite DB

  await db.run(
    "INSERT INTO login_attempts (email, visitor_id, created_at) VALUES (?, ?, ?)",
    email,
    visitorId,
    Date.now()
  );
}

👉 Commit

Identifying visitors could be tricky as most bots are already aware of standard methods such as cookies or IP-based identification. That's exactly the problem FingerprintJS solves! It is an open-source library for bullet-proof device identification and it has a Pro version with more advanced features such as persistent visitor IDs or backend-based fingerprinting.

In this demo, we're going to use an official React library that can be integrated in just few minutes.

First of all, we will need to wrap our app in FpjsProvider to allow underlying components to use the library:

// app/root.tsx
{/* 
  By wrapping the <Outlet /> with <FpjsProvider /> we ensure that all routes
  in our app can properly access Fingerprint's API
*/}
<FpjsProvider loadOptions={{ apiKey: FPJS_API_KEY }}>
  <Outlet />
</FpjsProvider>

Remix provides an app/root.tsx file where you can customize the app-wide layout. Now, in our login component, we're going to use the useVisitorData hook to obtain the visitor id. Note that this hook is asynchronous, and this is why it returns the isLoading flag.

// routes/login.tsx
import { useVisitorData } from "@fingerprintjs/fingerprintjs-pro-react";

// Get the current browser identifier
const { isLoading: isVisitorIdLoading, data: visitorData } = useVisitorData();

const visitorId = visitorData?.visitorId;

You can also pass { immediate: false } to the hook if you'd like to manually trigger the identification process.

👉 Commit

The remaining part is figuring out if we want to block current requests when the number of attempts exceeds the threshold. To do that, we just have to write a simple query: it will get the number of attempts coming from this visitor within a fixed time interval:

export async function shouldThrottleLoginRequest(
  visitorId: string,
  options: ThrottlingOptions = { maxAttempts: 5, periodMins: 5 }
): Promise<boolean> {
  // when does the throttling window start
  const startTime = Date.now() - options.periodMins * 60 * 1000;

  // get the number of failed login attempts for the current visitor
  const row = await db.get<{ numAttempts: number }>(
    `SELECT count(*) as numAttempts FROM login_attempts 
      WHERE visitor_id = (?) AND created_at > (?)`,
    visitorId,
    startTime
  );

  // threshold exceeded - block the request!
  if (Number(row?.numAttempts) > options.maxAttempts) {
    return true;
  }

  return false;
}

👉 Commit

And that's it! Now our login form is protected against credential stuffing attacks. Obviously, there is a lot of things you can further improve in this implementation, but I hope this tutorial helped you get a basic glimpse of how visitor-based request throttling works.

Further Reading

Running the code locally

Just run npm run dev and you're good to go.