Instantly Gain Your Users' Trust with Password Breach Detection in Signup Forms
·17 mins
Security
Reactjs
Go
Javascript
Haveibeenpwned
indra susila
safe code, sleep well
Table of Contents
Table of Contents
#. Basic Info
This is a “casual” article that talks about how data breaches can be used as opportunities, based on how various tech industries are already making use of them.
An in-depth technical guide on implementing a password breach checker for web applications using ReactJS and Go.
1. Introduction
At present, not all users in the digital era “realize the risks” of data breaches. This is understandable, as not everyone pays attention to cybersecurity—even software engineers.
But when it comes to passwords, it’s a completely different story.
It doesn’t matter if someone is tech-savvy or not—passwords will always be a special concern for users.
Even if they’re used to logging in with Gmail, they’d still freak out if their password got leaked.
Why? Because passwords are deeply personal.
For example, even if you don’t use personal thing for your passwords (which is good), you’ll still tend to pick something memorable—
whether it’s a special character, a favorite anime character, or a combination that you hope will keep playing in your mind for years, maybe even for life.
2. The Hidden Role of Data Breaches in Password Checks
Data breaches have been happening for over a decade, affecting major companies like Adobe, Twitter, Facebook, Gmail, Canva, Tokopedia, and more.
[source]
While web security becomes more complicated, some developers take advantage of data breaches and use them for extra features as a warning
“the user data has been exposed”. It is advised not to reuse it for security reasons.
2.1 haveibeenpwned (HIBP)
As far as I know, the only publicly accessible source for data breaches is HaveIBeenPwned(CMIIW).
In fact, almost all paid security services are integrated with it.
HaveIBeenPwned allows to search across multiple data breaches from various domains.
It was created by Troy Hunt, a cybersecurity expert who also serves as a Microsoft Regional Director.
To date, more than 1 billion breached passwords have been collected and integrated into services like 1Password,
Mozilla, GitHub, and many others,
I believe that password breach checking has the potential to make a deeply positive impact on users personally.
It can positively influence user behavior,
enhance the user experience, and address psychological concerns.
Some people may be triggered when they realize their password has been exposed,
especially if they’ve been using it for years.
When your platform advises and forces them to change their password because it’s compromised and unsafe,
if you make a positive impression at that moment,
they will certainly feel greatly assisted and may even update their password on other platforms. And that means,
it’s not just the new password they will remember, but also your product.
and That’s it. That is the expected behavior.
this is just my personal opinion, and I don’t have many sources to back it up.
2.2 Pragmatic Analysis
In another case, if you’re familiar with Hostinger.com (a cloud hosting provider),
you’ll notice that there’s no data breach checking during signup on that domain.
However, if you buy the product from Hostinger Indonesia (hostinger.co.id),
you’ll find that password breach checking is applied.
It’s interesting to see this difference:
while both domains belong to the same company, but Indonesian domain offers an additional feature.
In my opinion, Hostinger has really played its role well in Indonesia.
If you’re truly aware of the situation in Indonesia, you’d know that over the past 3 years,
many incidents related to cybersecurity and data breaches have occurred.
The chart above shows data from 2021 to 2023,
and Indonesia has seen a significant rise in data breaches
[source],
To make matters worse,
the Indonesian government has faced serious data breach issues since July 2024 .
[source &]
[hot news]
The logical consequence of this chart is that data breaches in Q2 2024 should show a noticeable increase,
making this case even more relevant and offering an opportunity to take advantage of the situation.
again, this is my personal opinion, and I don’t have many sources to back it up.
3. Technical Implementation
We will use HIBP as a dataset library, and so far, we can do it in two ways:
Using the HIBP API. It’s free to use, but has a rate limit. or
Self-hosting HIBP,
we need to download the password breach list and host it on our server.
For privacy reasons, HIBP uses K-Anatomy.
this means that the password is not sent to the server in plain text.
we need to hash the password before sending it to the server. The password is hashed using the SHA-1 algorithm.
In general, the flow is roughly like this.
sequenceDiagram
autonumber
box rgb(248, 255, 240) Frontend
actor User
participant Frontend
end
box rgb(245, 251, 255) Backend
participant HIBP_API
participant HIBP_Database
end
User->>Frontend: Enter plain text Password
Note right of User: sukses100%
Frontend->>Frontend: Hash Password (SHA-1)
Note right of Frontend: 7a58a286641ec6
Frontend->>HIBP_API: Send First 5 Characters of Hash (Prefix)
Note right of Frontend: 7a58a
HIBP_API->>HIBP_Database: Query Prefix
HIBP_Database-->>HIBP_API: Return list of Matching Hashes
Note left of HIBP_Database: 7a58a286641ec6:2
Note left of HIBP_Database: 7a58aa8664112d:1
Note left of HIBP_Database: 7a58ab86641ec1:7
HIBP_API-->>Frontend: Respond with Matches as suffix list
Note left of HIBP_API: 286641ec6:2
Note left of HIBP_API: a8664112d:1
Note left of HIBP_API: b86641ec1:7
Frontend->>Frontend: Compare Full Hash Locally
Frontend-->>User: Notify if Compromised
The password is sukses100% as a plaintext, and
Frontend is hashed into a SHA-1 hash like:
7a58a286641ec6
The first 5 characters of the hash (called the prefix) are extracted as 7a58a.
this prefix is sent to the HIBP API to query for possible matches.
The HIBP API server sends a prefix query to the HIBP database to find matching hashes.
If a match is found, the HIBP database returns a all hash list of matching hashes with pawned counts info.
pawned count is The numbers after each hash, represent the number of times that password was seen in breaches.
example response:
The client receives the list of possible matches and compares the suffix hash of the user’s password to the list of returned suffix hashes.
If a match is found, the user is alerted that their password is compromised and should be changed.
4. Frontend Implementation (Reactjs)
sequenceDiagram
box rgb(248, 255, 240) Frontend
actor User
participant Frontend as Nextjs/Remix
end
participant HIBP_API
User->>Frontend: Enter plain text Password
Note right of User: sukses100%
Frontend->>Frontend: Hash Password (SHA-1)
Note right of Frontend: 7a58a286641ec6...
Frontend->>HIBP_API: Send First 5 Characters of Hash (Prefix)
Note right of Frontend: 7a58a
HIBP_API-->>Frontend: Respond with Matches
Note left of HIBP_API: 286641ec6:2
Note left of HIBP_API: 286641121:1
Note left of HIBP_API: 286641129:7
Frontend->>Frontend: Compare Full Hash Locally
Frontend-->>User: Notify if Compromised
To hash, send, and compare, basically we’ll be using some simple logic in TypeScript like this.
importsha1from"sha1";/**
* Checks if a password is safe using the HIBP API.
* @param password The password to check.
* @returns Promise<boolean> - `true` if the password is safe, `false` if it is compromised.
*/exportasyncfunctionisPasswordSafety(password: string):Promise<boolean>{try{// Step 1: Hash the password using SHA1
consthashedPassword=sha1(password);constprefix=hashedPassword.substring(0,5);// First 5 characters of the hash
constsuffix=hashedPassword.substring(5);// Remaining characters of the hash
// Step 2: Fetch data from HIBP API for the hash prefix
constresponse=awaitfetch(`https://api.pwnedpasswords.com/range/${prefix}`);// Step 3: Check if the response is successful
if(!response.ok){thrownewError(`Failed to fetch: ${response.status} - ${response.statusText}`);}// Step 4: Extract the list of hashes and pawned counts
constdata=awaitresponse.text();consthashes=data.split("\n");// Split response into individual hash entries
// Step 5: Find the hash suffix in the returned list
constfoundEntry=hashes.find((hash)=>{const[hashSuffix]=hash.split(":");// Extract the hash suffix
returnhashSuffix.toLowerCase()===suffix.toLowerCase();// Compare suffixes (case-insensitive)
});// Step 6: Return true if no entry is found, otherwise false
returnfoundEntry===undefined;// Safe if no match is found
}catch(error){console.error("Error checking password:",error);// Treat any error as a signal that the password might not be safe
returnfalse;}}
for better approach we will build customeHooks, it will help us to reuse the code.
// usePasswordSafety.tsx
import{useState}from"react";importsha1from"sha1";// need install sh1 as dependency
interfacePawnedResult{status:"safe"|"pawned"|"error"|undefined;error: string|null;// Stores any error messages encountered during the process
isLoading: boolean;// Tracks the loading state of the API request
numberOfPawned: number;// Number of times the password has been pawned (extracted from the API response)
}// Custom hook for checking if a password is compromised using the HIBP API
exportfunctionusePasswordSafety() {const[state,setState]=useState<PawnedResult>({status: undefined,error: null,isLoading: false,numberOfPawned: 0,});// Main function to check a password using the HIBP API
constisPasswordSafety=async(password: string):Promise<void>=>{setState((prev)=>({...prev,isLoading: true,error: null}));try{// Step 1: Hash the password using SHA1
consthashedPassword=sha1(password);constprefix=hashedPassword.substring(0,5);// Extract the first 5 characters of the hash
constsuffix=hashedPassword.substring(5);// Extract the remaining characters of the hash
// Step 2: Make an API request to HIBP with the hash prefix
constresponse=awaitfetch(`https://api.pwnedpasswords.com/range/${prefix}`);// Step 3: Handle non-OK responses
if(!response.ok){thrownewError(`Failed to fetch: ${response.status} - ${response.statusText}`);}// Step 4: Parse the API response as text (contains hash suffixes and pawned counts)
constdata=awaitresponse.text();consthashes=data.split("\n");// Split the response into individual lines
// Step 5: Find the user's hash suffix in the list of hashes
constfoundEntry=hashes.find((hash)=>{const[hashSuffix]=hash.split(":");// Split each line to isolate the hash suffix
returnhashSuffix.toLowerCase()===suffix.toLowerCase();// Compare suffixes (case-insensitive)
});// Step 6: Extract the number of times the password has been pawned
constnumberOfPawned=foundEntry?parseInt(foundEntry.split(":")[1]||"0",10)// Get the count after the ":"
:0;// Step 7: Update the state with the results (success case)
setState({status: foundEntry===undefined?"safe":"pawned",// true is safe
error: null,isLoading: false,numberOfPawned,});}catch(error){// Step 8: Handle errors gracefully by updating the state
consterrorMessage=errorinstanceofError?error.message:"Unknown error occurred.";// Use a descriptive error message
setState({status:"error",// Result is null due to the error
error: errorMessage,isLoading: false,numberOfPawned: 0,});}};// Return the current state and the function to check passwords
return{...state,isPasswordSafety};}
// SignUpForm.tsx
import{useForm}from"react-hook-form";importPasswordShowBtnfrom"./components/PasswordShowBtn";import{password_validation}from"./schema/password_validation";import{usePasswordSafety}from"./hooks/usePasswordSafety";interfaceIFormInput{password: string;}functionApp() {const[showPassword,setShowPassword]=useState(false);const{status,error,isLoading,numberOfPawned,isPasswordSafety}=usePasswordSafety();const{register,handleSubmit,formState:{errors},}=useForm<IFormInput>({defaultValues:{password:"",},});consttogglePasswordVisibility=()=>{setShowPassword((prev)=>!prev);};constonSubmit=async(data: IFormInput)=>{// When the form is submitted, check the password with the API
awaitisPasswordSafety(data.password);};return(<formonSubmit={handleSubmit(onSubmit)}><div><inputtype={showPassword?"text":"password"}// Toggle antara password dan text
placeholder="Password"{...register("password",password_validation)}defaultValue=""/>{errors.password&&<p>{errors.password.message}</p>}<PasswordShowBtntype="button"onClick={togglePasswordVisibility}>{showPassword?"Hide":"Show"}</PasswordShowBtn></div><buttondisabled={isLoading}type="submit">{isLoading?"Checking password...":"Submit"}</button>{/* Show result or error */}{status==="pawned"&&!isLoading&&(<p>Wefoundthispasswordinadataleak{numberOfPawned}times.Forpeaceofmind,werecommendchoosinganewone.{`${status}`}</p>)}{status==="safe"&&!isLoading&&(<div>yourpasswordishealth</div>)}{error&&!isLoading&&<p>Error:{error}</p>}</form>);}constrootElement=document.getElementById("root");ReactDOM.render(<App/>,rootElement);
Next up, we’re gonna keep setting up our self-hosted HIBP.
For learning purpose, we’ll roll with Go and its standard HTTP library, —it’s simpler, easier to understand, and quicker to set up.
5. Backend Implementation (Go with http standar lib)
Since Go 1.22.1 was released with a fancy HTTP standard library update, I personally prefer to use that instead of bringing in framework abstractions. Another good news is the performance; it’s quite good. You can deep dive into benchmarking Go’s stdlib vs Gin vs Fiber in EKS Kubernetes here. To get the password dataset from Have I Been Pwned, you need a subscription.
You can sign up for it here..
Without an API key, you won’t be able to download the dataset from HIBP.
but if you’re not familiar to #C or .net, you can use alternative tools like python, Rust, Nodejs, and PHP . don’t worry, the process and flow are pretty much the same across the board.
And finally, you’ll get a huge .txt file that contains the list of pwned passwords.
Beware! Don’t open the file with regular editors like VSCode or Neovim—it’ll take forever and might freeze your computer. You should use a specialized editor for handling huge files, like less, klogg, or something similar.
5.1 converting dataset .txt to sqlite
Column Name
Data Type
Constraints
prefix
TEXT
NOT NULL, PRIMARY KEY
suffix
TEXT
NOT NULL, PRIMARY KEY
occurrences
INTEGER
NOT NULL
Using OS “file reads” in Go is still fine, but sometimes we need a more maintainable solution. I prefer converting it to an SQLite database—it’s easier to work with and quite fast. We’ll need a script to transform the .txt file into SQLite.
import("bufio""database/sql""log""os""strings"_"github.com/mattn/go-sqlite3")funcmain(){// Open the text file containing the password hashes and their occurrences.
file,err:=os.Open("../data/hibp_example.txt")deferfile.Close()db,err:=sql.Open("sqlite3","../data/hbip.db")deferdb.Close()_,err=db.Exec(`CREATE TABLE IF NOT EXISTS pwned_passwords (
prefix TEXT NOT NULL,
suffix TEXT NOT NULL,
occurrences INTEGER NOT NULL,
PRIMARY KEY (prefix, suffix)
)`)// Begin a new database transaction to batch insert data for better performance.
tx,err:=db.Begin()// Prepare an SQL statement to insert or ignore duplicate records into the table.
stmt,err:=tx.Prepare("INSERT OR IGNORE INTO pwned_passwords (prefix, suffix, occurrences) VALUES (?, ?, ?)")deferstmt.Close()// Create a scanner to read the text file line by line.
scanner:=bufio.NewScanner(file)forscanner.Scan(){line:=scanner.Text()parts:=strings.Split(line,":")// Skip lines that don't follow the expected format
iflen(parts)!=2{continue}hash:=parts[0]// The full hash value from the line
count:=parts[1]// The occurrence count from the line
prefix:=hash[:5]// Extract the first 5 characters as the prefix
suffix:=hash[5:]// Extract the remaining characters as the suffix
_,err:=stmt.Exec(prefix,suffix,count)iferr!=nil{log.Printf("Failed to insert record: %v",err)}}// Commit the transaction to finalize the batch insert into the database.
iferr:=tx.Commit();err!=nil{log.Fatalf("Failed to commit transaction: %v",err)}log.Println("Data conversion completed.")}
You need to run that script to prepare the dataset as an SQLite db.
packagedatabaseimport("database/sql""fmt"_"github.com/mattn/go-sqlite3")typeDBstruct{*sql.DB}funcInitDatabase(dbPathstring)(*sql.DB,error){ifdbPath==""{returnnil,fmt.Errorf("database path is empty")}db,err:=sql.Open("sqlite3",dbPath)iferr!=nil{returnnil,fmt.Errorf("failed to open database: %v",err)}returndb,nil}// finding suffix base on prefix
funcGetSuffixesByPrefix(db*DB,prefixstring)([]string,error){query:=`SELECT suffix FROM hibp WHERE prefix = ?`rows,err:=db.Query(query,prefix)iferr!=nil{returnnil,fmt.Errorf("failed to query suffixes: %v",err)}deferrows.Close()varsuffixes[]stringforrows.Next(){varsuffixstringiferr:=rows.Scan(&suffix);err!=nil{returnnil,fmt.Errorf("failed to scan suffix: %v",err)}suffixes=append(suffixes,suffix)}iferr:=rows.Err();err!=nil{returnnil,fmt.Errorf("error iterating over rows: %v",err)}returnsuffixes,nil}
You can implement the database package in the router handler andBasically, I just made this project more simple and similar like Hono/Express framework.
packagehandlerimport("database/sql""fmt""net/http")// query handler
funcCheckHandler(db*sql.DB)http.HandlerFunc{returnfunc(whttp.ResponseWriter,r*http.Request){ifr.Method!=http.MethodGet{http.Error(w,"Method not allowed",http.StatusMethodNotAllowed)return}// fetch prefix from url query
prefix:=r.URL.Query().Get("prefix")iflen(prefix)!=5{http.Error(w,"Prefix must be exactly 5 characters",http.StatusBadRequest)return}rows,err:=db.Query("SELECT suffix, COUNT(*) FROM pwned_passwords WHERE prefix = ? GROUP BY suffix",prefix)iferr!=nil{http.Error(w,"Internal server error",http.StatusInternalServerError)return}deferrows.Close()varresultstringforrows.Next(){varsuffixstringvaroccurrencesintiferr:=rows.Scan(&suffix,&occurrences);err!=nil{http.Error(w,"Error scanning result",http.StatusInternalServerError)return}// Ensure only the suffix is returned
result+=fmt.Sprintf("%s:%d\n",suffix,occurrences)}ifresult==""{w.WriteHeader(http.StatusNotFound)fmt.Fprint(w,"No suffix found")return}// returning text format as a response
w.Header().Set("Content-Type","text/plain")w.WriteHeader(http.StatusOK)fmt.Fprint(w,result)}}
I also providing full source code at github, you can check it out [here]. just make sure Go 1.23 is installed on your machine cz I haven’t finished the Podman/Docker config yet.
6. Conclusion
The budget for this implementation is cheap, even you can do it for free. maybe you’ll got rate limits, but it’s only affects the client network as a frontend. There is no significant impact on your server, except when you are running a self-hosted HBIP, which might incur some hosting costs.