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
.
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
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
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
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
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.
Just run npm run dev
and you're good to go.