DEV Community

Cover image for Build a powerful password meter with Cloudflare Workers, Next.js, and zxcvbn.
Jay @ Designly
Jay @ Designly

Posted on • Originally published at blog.designly.biz

Build a powerful password meter with Cloudflare Workers, Next.js, and zxcvbn.

Password security is a concern that is all-to-often either completely overlooked or woefully implemented. Thankfully, there's a really great library I discovered that takes all of the guess work out of scoring passwords. It's called zxcvbn. It's so named because users often use sequential keys on the keyboard to make their passwords more memorable.

There's just one problem with zxcvbn, the bundle size is nearly 800kB! That's a deal breaker for any front-end code.

As you may have already guessed from the title, I have a great solution to this problem: CloudFlare Workers! We're going to create a password scoring microservice! 🤗 CloudFlare Workers are blazing fast edge functions and once you set this up, you'll be able to use it for any future projects as well.

We're going to start by creating a worker using CloudFlare's wrangler cli tool. Setting it up and publishing it will take less than 5 minutes. Lastly, we'll create a create-next-app project (you could do create-react-app as well) and create a really slick front-end password feedback component based on the zxcvbn result we get from our service.

Password Meter Demo

Setting Up Our CloudFlare Worker

Getting started with CloudFlare is a no-brainer. I'm going to assume you already have a free CloudFlare account.

npm create cloudflare@latest zxcvbn-worker
Enter fullscreen mode Exit fullscreen mode

Choose whether you want to use Typescript (I recommend so), git for version control (yes) and then opt in to go ahead and deploy your app. You'll need to go through the authentication process, then select your team account and it will be live in less than 30 seconds.

We only need to install one dependency:

npm i zxcvbn
npm i -D @types/zxcvbn
Enter fullscreen mode Exit fullscreen mode

Next, open up the project in your favorite editor and edit index.ts:

// index.ts

import { checkPasswordStrength } from './lib/pw';

interface I_CheckPasswordReqestBody {
    password: string;
}

// Function to generate CORS headers
function getCommonHeaders() {
    return {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'POST, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type',
        'Cache-Control': 'no-store',
        'Content-Type': 'application/json',
    };
}

export default {
    async fetch(request, env, ctx): Promise<Response> {
        // Handle CORS preflight request
        if (request.method === 'OPTIONS') {
            return new Response(null, {
                headers: getCommonHeaders(),
            });
        }

        // Allow only POST requests
        if (request.method !== 'POST') {
            return new Response(null, { status: 405, headers: getCommonHeaders() });
        }

        try {
            // Parse the request body
            const body = (await request.json()) as I_CheckPasswordReqestBody;
            const { password } = body;

            // Check if the password field is missing or empty
            if (!password) {
                return new Response(JSON.stringify({ error: 'Password is required' }), {
                    status: 400,
                    headers: {
                        ...getCommonHeaders(),
                    },
                });
            }

            // Check the password strength
            const result = checkPasswordStrength(password);

            return new Response(JSON.stringify(result), {
                headers: {
                    ...getCommonHeaders(),
                },
            });
        } catch (error) {
            return new Response(JSON.stringify({ error: 'Invalid request' }), {
                status: 400,
                headers: {
                    ...getCommonHeaders(),
                },
            });
        }
    },
} satisfies ExportedHandler<Env>;
Enter fullscreen mode Exit fullscreen mode

Lastly, we'll need to create our helper function:

// lib/pw.ts

import zxcvbn from 'zxcvbn';

export type T_CheckPasswordStrengthResult = ReturnType<typeof zxcvbn>;

export function checkPasswordStrength(password: string): T_CheckPasswordStrengthResult {
    return zxcvbn(password);
}
Enter fullscreen mode Exit fullscreen mode

That be it! Simply run npx wrangler deploy and test with Thunder Client or Postman.

Integrating with Our Front-End

Go ahead and spin up either a React or Next.js app and open the project up in your favorite editor.

I have some additional dependencies for the demo app (link at bottom), but the only one you really need is @types/zxcvbn, if you're using Typescript, of course (which you should). If you don't understand some of the Tailwind classes, that is because I'm using DaisyUI, a super-duper light plugin for Tailwind. DaisyUI offers themes and semantic color schemes (i.e. primary, secondary, error, etc.)

First, we'll create our password feedback component that we'll be able to use in any form that has a password:

// src/components/PasswordFeedback.tsx

import React from 'react';
import { FaRegTimesCircle, FaRegCheckCircle } from 'react-icons/fa';
import type { ZXCVBNResult } from 'zxcvbn';

interface Props {
    zxcvbn: ZXCVBNResult | null;
    minScore: number;
    className?: string;
    isLoading: boolean;
}

// Define color stops for each score level
const colors = ['#FF0000', '#FF4400', '#FFA200', '#FFFF00', '#37FF00'];
const words = ['Weak', 'Fair', 'Good', 'Strong', 'Very Strong'];

export default function PasswordFeedback({ zxcvbn, className = '', minScore, isLoading }: Props) {
    // Calculate the score index (0-4)
    const score = zxcvbn ? Math.min(zxcvbn.score, 4) : 0;

    // Create gradient stops based on score
    const gradientStops = colors
        .map((color, index) => `${color} ${(index / 4) * 100}%`)
        .slice(0, score + 1)
        .join(', ');

    // Width of the filled portion of the bar
    const widthPercentage = (score / 4) * 100;

    const feedbackDisplay = zxcvbn ? zxcvbn.crack_times_display.offline_slow_hashing_1e4_per_second : '_______';

    return (
        <div className={`flex flex-col ${className}`}>
            <div className="text-sm">
                <span className="font-bold">Password Strength:</span> {words[score]}
            </div>
            <div className="flex items-center gap-2 w-full mt-1">
                <div className="w-full h-4 bg-gray-200 rounded overflow-hidden relative">
                    <div
                        className="h-full"
                        style={{
                            width: `${widthPercentage}%`,
                            background: `linear-gradient(to right, ${gradientStops})`,
                        }}
                    ></div>
                </div>
                <div className="text-xl">
                    {score < minScore ? (
                        <FaRegTimesCircle className="text-red-500" />
                    ) : (
                        <FaRegCheckCircle className="text-green-500" />
                    )}
                </div>
            </div>
            <div className="text-xs text-gray-500 mt-1">
                {isLoading ? (
                    <>
                        <div className="flex items-center gap-2">
                            <div className="loading loading-xs" />
                            <span>Checking password strength...</span>
                        </div>
                    </>
                ) : (
                    <>
                        Your password would take&nbsp;
                        <span className="font-bold">{feedbackDisplay}</span>
                        &nbsp;to crack.
                    </>
                )}
            </div>
            {zxcvbn && zxcvbn.feedback.suggestions.length > 0 ? (
                <div className="text-xs text-gray-800 mt-4">
                    <span className="font-bold">Suggestions:</span>
                    <ul className="list-disc list-inside mt-2">
                        {zxcvbn.feedback.suggestions.map((suggestion, index) => (
                            <li key={index}>{suggestion}</li>
                        ))}
                    </ul>
                </div>
            ) : (
                <div className="h-12" />
            )}
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

This component accepts the zxcvbn response object and displays a nifty little password meter based on the score (0-4). It also displays any suggestions and the verbose "your password will take XX years to crack". All of this comes from zxcvbn!

Next, here is our helper function:

import type { ZXCVBNResult } from 'zxcvbn';

export default async function checkPasswordStrength(password: string): Promise<ZXCVBNResult> {
    const url = 'https://url-of-your-cloudflare-worker.com';
    const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ password }),
    });
    return await response.json();
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we'll put it all together in our form view:

'use client';

// Components
import React, { useState, useCallback, useEffect } from 'react';
import PasswordFeedback from '@/components/PasswordFeedback';

// Utilities
import checkPasswordStrength from '@/lib/passwordStrength';
import { debounce } from 'lodash-es';
import { usePoptart } from 'react-poptart';

// Types
import type { ZXCVBNResult } from 'zxcvbn';

const minScore = 3;

export default function HomeView() {
    const poptart = usePoptart();

    // State
    const [password, setPassword] = useState('');
    const [zxcvbn, setZxcvbn] = useState<ZXCVBNResult | null>(null);
    const [score, setScore] = useState<number>(0);
    const [error, setError] = useState<string | null>(null);
    const [isLoading, setIsLoading] = useState(false);

    // This function checks the password strength using the CloudFlare endpoint
    const handleCheckPasswordStrength = async (password: string) => {
        setIsLoading(true);
        try {
            const result = await checkPasswordStrength(password);
            setZxcvbn(result);
            setScore(result.score);
        } catch (err) {
            console.error(err);
        } finally {
            setIsLoading(false);
        }
    };

    // This is a debounced version of the password strength checker
    // eslint-disable-next-line react-hooks/exhaustive-deps
    const debouncedCheckPasswordStrength = useCallback(debounce(handleCheckPasswordStrength, 500), []);

    // Effects
    useEffect(() => {
        if (password) {
            debouncedCheckPasswordStrength(password);
        } else {
            setZxcvbn(null);
            setScore(0);
        }
    }, [password, debouncedCheckPasswordStrength]);

    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        setPassword(event.target.value);
        setError(null);
    };

    const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        if (score < minScore) {
            const message = 'Password is too weak';
            setError(message);
            poptart.push({ message, type: 'error' });
        } else {
            setError(null);
            poptart.push({ message: 'Password is strong!', type: 'success' });
        }
    };

    // Render
    return (
        <form className="w-full max-w-lg m-auto flex flex-col gap-6 p-6 border-2 rounded-xl" onSubmit={handleSubmit}>
            <h1 className="text-2xl font-bold text-center">Password Strength Checker</h1>
            <label className="form-control w-full">
                <div className="label">
                    <span className="label-text">Password</span>
                </div>
                <input
                    type="password"
                    placeholder="Enter password"
                    className="input input-bordered w-full"
                    value={password}
                    onChange={handleChange}
                />
                <div className="label">
                    <span className="label-text-alt text-error">{error}</span>
                </div>
            </label>
            <PasswordFeedback zxcvbn={zxcvbn} minScore={minScore} isLoading={isLoading} />
            <button className="btn btn-primary" type="submit">
                Submit
            </button>
        </form>
    );
}
Enter fullscreen mode Exit fullscreen mode

Here I'm using useCallback along with lodash.debounce to debounce the calls to the worker so we're not going crazy. It leaves a little bit of a delay after you finish typing, but I think it's quite acceptable UX-wise.

The component is relatively good on cumulative layout shift (CLF). The suggestions (if any) might extend the height a small amount, but other than that it's pretty solid!

Resources

  1. Demo Site
  2. Demo Site Repo
  3. CloudFlare Worker Repo
  4. zxcvbn

Thank You!

Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify!

Current Projects

  • Snoozle.io- An AI app that generates bedtime stories for kids ❤️
  • react-poptart - A React Notification / Alerts Library (under 20kB)
  • Spectravert - A cross-platform video converter (ffmpeg GUI)
  • Smartname.app - An AI name generator for a variety of purposes

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.

Top comments (0)