Please purchase the course to watch this video.

Full Course
Checking whether a password has been exposed in data breaches is essential for maintaining strong account security. By leveraging the Have I Been Pwned API, developers can create applications that securely check if an inputted password appears in leaked databases without revealing the user's actual password. This is achieved through a k-anonymity model, where a SHA-1 hash of the password is generated and only the first five characters of the hash are sent to the API. The API then returns a list of hash suffixes matching that prefix, allowing the application to safely determine if the password has been compromised while preserving user privacy. Implementing this process in Go involves securing user input, hashing the password, making HTTP requests to the API, and efficiently searching the response for a match—demonstrating practical techniques for integrating robust, real-world security checks into authentication workflows.
In this lesson, we're going to take all of the concepts that we learned over the past few lessons, as well as how to perform HTTP requests, in order to create an application where we can input a password and check to see if it's leaked.
We'll be doing this using the fantastic Have I Been Pwned service, which allows you to submit a password and see whether or not it appears on a leaked password database.
The Have I Been Pwned API
To achieve this, we're going to make use of the HTTP API provided by Have I Been Pwned, which we can use without any API keys or registering with the service.
The k-Anonymity Model
This API is actually rather interesting to use, as it makes use of a k-anonymity model in order to be able to search for leaked passwords without actually exposing the original password we're searching for.
How It Works
- Hash the password using SHA1
- Send only the first 5 characters of the hash to the API
- Receive all hash suffixes that match that prefix
- Check locally if our full hash matches any returned suffix
This is known as a k-anonymity model - a process of being able to send up partial data, receive all the responses that match that partial data without ever leaking the full data to a server.
Security Benefits
- Double layer of protection:
- First: The hash itself (though vulnerable to rainbow tables)
- Second: Only first 5 characters sent (remaining 35 characters nearly impossible to brute force)
- No password exposure: Original password never sent to the server
- Privacy preservation: Even if traffic is intercepted, full password hash isn't revealed
Building the Application
Let's create our own password checking tool using this secure API.
Project Setup
mkdir pwned
cd pwned
go mod init dreamsofcode.io/pwned
Create the main structure:
package main
import (
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"golang.org/x/term"
)
func main() {
// We'll implement this step by step
}
Step 1: Secure Password Input
First, we need to securely accept a password from the user:
go get golang.org/x/term
func main() {
log.SetFlags(0) // Remove timestamp from logs
// Prompt for password
fmt.Print("Password to check: ")
// Read password securely (no echo)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
log.Fatal("Error reading password:", err)
}
fmt.Println() // Add newline after password input
// Continue with hashing...
}
Step 2: Hash the Password
Next, we hash the password using SHA1:
func main() {
// ... password input code ...
// Create SHA1 hasher
hasher := sha1.New()
// Write password to hasher
hasher.Write(password)
// Get the hash
hashedPassword := hasher.Sum(nil)
// Convert to uppercase hex string (API expects uppercase)
hash := strings.ToUpper(hex.EncodeToString(hashedPassword))
fmt.Printf("Password hash: %s\n", hash) // For demonstration
}
Understanding the Hash
When we hash "password", we get:
5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
- Prefix (first 5 chars):
5BAA6
- Suffix (remaining 35 chars):
1E4C9B93F3F0682250B6CF8331B7EE68FD8
Step 3: Make the API Request
Now we construct the API request using only the first 5 characters:
func main() {
// ... previous code ...
// Get prefix (first 5 characters)
prefix := hash[:5]
// Construct API URL
baseURL := "https://api.pwnedpasswords.com/range"
fullURL := fmt.Sprintf("%s/%s", baseURL, prefix)
// Create context with cancellation
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
log.Fatal("Error creating request:", err)
}
// Send request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Fatal("Error making request:", err)
}
defer resp.Body.Close()
// Process response...
}
Step 4: Process the API Response
The API returns a list of hash suffixes, one per line:
1E4C9B93F3F0682250B6CF8331B7EE68FD8:21952085
A94A8FE5CCB19BA61C4C0873D391E987982FBBD3:4289
...
Each line contains:
- Hash suffix: The remaining 35 characters
- Count: Number of times this password appeared in breaches
import (
"bufio"
// ... other imports
)
func main() {
// ... previous code ...
// Get suffix (characters 5 onwards)
suffix := hash[5:]
// Scan response line by line
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
// Check if this line starts with our suffix
if strings.HasPrefix(line, suffix) {
fmt.Println("❌ Uh oh, password is pwned!")
return
}
}
if err := scanner.Err(); err != nil {
log.Fatal("Error reading response:", err)
}
fmt.Println("✅ Password is safe!")
}
Complete Implementation
Here's the full working application:
package main
import (
"bufio"
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"golang.org/x/term"
)
func main() {
log.SetFlags(0)
// Get password securely
fmt.Print("Password to check: ")
password, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
log.Fatal("Error reading password:", err)
}
fmt.Println()
// Hash the password
hasher := sha1.New()
hasher.Write(password)
hash := strings.ToUpper(hex.EncodeToString(hasher.Sum(nil)))
// Split hash into prefix and suffix
prefix := hash[:5]
suffix := hash[5:]
// Make API request
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
url := fmt.Sprintf("https://api.pwnedpasswords.com/range/%s", prefix)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
log.Fatal("Error creating request:", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Fatal("Error making request:", err)
}
defer resp.Body.Close()
// Check if password is compromised
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, suffix) {
// Extract count from line (format: SUFFIX:COUNT)
parts := strings.Split(line, ":")
if len(parts) == 2 {
fmt.Printf("❌ Uh oh, password is pwned! Found %s times in breaches.\n", parts[1])
} else {
fmt.Println("❌ Uh oh, password is pwned!")
}
return
}
}
if err := scanner.Err(); err != nil {
log.Fatal("Error reading response:", err)
}
fmt.Println("✅ Password is safe!")
}
Testing the Application
Let's test with some common passwords:
go run main.go
# Test 1: Common password
Password to check: password
# Output: ❌ Uh oh, password is pwned! Found 21952085 times in breaches.
# Test 2: Secure password
Password to check: MyVerySecureP@ssw0rd2024!
# Output: ✅ Password is safe!
# Test 3: Another compromised password
Password to check: helloworld123
# Output: ❌ Uh oh, password is pwned! Found in breaches.
How the k-Anonymity Model Works
Example Walkthrough
- User enters:
password
- SHA1 hash:
5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
- Send to API: Only
5BAA6
(first 5 characters) - API returns: All suffixes starting with
5BAA6
- Local check: Look for
1E4C9B93F3F0682250B6CF8331B7EE68FD8
in results
Security Analysis
What if someone intercepts the traffic?
- They see: API request to
/range/5BAA6
- They get: List of ~300-400 hash suffixes
- They don't know: Which specific hash (and therefore password) we're checking
Why is this secure?
- First 5 chars alone: Could represent thousands of different passwords
- Full hash: Would require brute-forcing 2^35 combinations (computationally infeasible)
- No reverse lookup: Even with the full hash, determining the original password is difficult
Real-World Applications
This approach has practical applications in:
1. User Registration Systems
func validatePassword(password string) error {
if isPasswordPwned(password) {
return errors.New("this password has been found in data breaches, please choose another")
}
return nil
}
2. Password Change Flows
func changePassword(userID string, newPassword string) error {
if isPasswordPwned(newPassword) {
return errors.New("cannot use a compromised password")
}
// Continue with password change...
}
3. Security Auditing
func auditUserPasswords(users []User) []CompromisedUser {
var compromised []CompromisedUser
for _, user := range users {
if isPasswordPwned(user.PasswordHash) {
compromised = append(compromised, user)
}
}
return compromised
}
Key Concepts Learned
1. Secure Input Handling
- Using
golang.org/x/term
for password input without echo - Proper context handling for HTTP requests
2. Cryptographic Hashing
- SHA1 implementation for password hashing
- Hexadecimal encoding and case conversion
3. HTTP API Integration
- RESTful API consumption
- Response parsing and validation
4. Privacy-Preserving Techniques
- k-anonymity model implementation
- Minimizing data exposure while maintaining functionality
Enhanced Version with Additional Features
Here's an extended version with more features:
package main
import (
"bufio"
"context"
"crypto/sha1"
"encoding/hex"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"time"
"golang.org/x/term"
)
func main() {
var (
verbose = flag.Bool("verbose", false, "Show detailed information")
timeout = flag.Duration("timeout", 10*time.Second, "Request timeout")
)
flag.Parse()
log.SetFlags(0)
// Get password
fmt.Print("Password to check: ")
password, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
log.Fatal("Error reading password:", err)
}
fmt.Println()
if len(password) == 0 {
log.Fatal("Password cannot be empty")
}
// Check password
result, err := checkPassword(string(password), *timeout, *verbose)
if err != nil {
log.Fatal("Error checking password:", err)
}
if result.Compromised {
fmt.Printf("❌ Password is compromised! Found %d times in breaches.\n", result.Count)
if *verbose {
fmt.Println("Consider using a unique, strong password with a mix of:")
fmt.Println(" • Uppercase and lowercase letters")
fmt.Println(" • Numbers and special characters")
fmt.Println(" • At least 12 characters in length")
}
} else {
fmt.Println("✅ Password appears to be safe!")
if *verbose {
fmt.Println("This password was not found in known data breaches.")
}
}
}
type PasswordResult struct {
Compromised bool
Count int
}
func checkPassword(password string, timeout time.Duration, verbose bool) (*PasswordResult, error) {
// Hash password
hasher := sha1.New()
hasher.Write([]byte(password))
hash := strings.ToUpper(hex.EncodeToString(hasher.Sum(nil)))
if verbose {
fmt.Printf("Checking hash prefix: %s...\n", hash[:5])
}
prefix := hash[:5]
suffix := hash[5:]
// Create request with timeout
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
url := fmt.Sprintf("https://api.pwnedpasswords.com/range/%s", prefix)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
// Add user agent
req.Header.Set("User-Agent", "pwned-checker/1.0")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
// Parse response
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, suffix) {
parts := strings.Split(line, ":")
if len(parts) == 2 {
count, err := strconv.Atoi(parts[1])
if err != nil {
return &PasswordResult{Compromised: true, Count: 0}, nil
}
return &PasswordResult{Compromised: true, Count: count}, nil
}
return &PasswordResult{Compromised: true, Count: 0}, nil
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
return &PasswordResult{Compromised: false, Count: 0}, nil
}
Summary
We've successfully created a secure password checking application that:
✅ Securely accepts passwords without echoing to terminal
✅ Uses cryptographic hashing (SHA1) as required by the API
✅ Implements k-anonymity to protect user privacy
✅ Makes HTTP requests with proper context and error handling
✅ Provides actionable feedback about password security
By making use of hashing, secure input, and the k-anonymity model provided by the Have I Been Pwned API, we've managed to do this in a secure way that has real-world value for authentication systems.
What's Next?
In the next lesson, we're going to look at another common form of providing sensitive data to an application, which is to make use of environment variables.
🔒 Security Considerations
Important Notes:
- This tool is for educational purposes and personal use
- Always use HTTPS for API requests (which we do)
- Consider rate limiting for production applications
- The SHA1 hash is used because that's what the API requires, not for production password storage
- For production password hashing, use bcrypt, scrypt, or Argon2
Enhanced CLI with Flags
Extend the basic password checker with command-line flags to make it more user-friendly and configurable.
Requirements:
- Add a
--verbose
flag that shows detailed information including the hash prefix being checked - Add a
--timeout
flag to configure the HTTP request timeout (default: 10 seconds) - Add a
--help
flag that displays usage information - Include proper error handling for invalid flag values
Bonus: Add a --quiet
flag that only outputs the result (✅ or ❌) without additional text.
Error Handling and Resilience
Improve the application's robustness by implementing comprehensive error handling and recovery mechanisms.
Requirements:
- Handle network timeouts gracefully with retry logic (max 3 retries with exponential backoff)
- Add proper error messages for different failure scenarios:
- Network connectivity issues
- API rate limiting (status 429)
- Invalid API responses
- Interrupted user input (Ctrl+C)
- Implement graceful shutdown using context cancellation
- Add logging levels (error, warning, info) with timestamps
Testing: Test with various failure scenarios like disconnected internet or invalid API endpoints.
API Response Caching
Implement a caching mechanism to avoid repeated API calls for the same hash prefixes.
Requirements:
- Create an in-memory cache that stores API responses by hash prefix
- Add cache expiration (e.g., 1 hour) to ensure data freshness
- Include cache statistics (hits, misses, size) that can be displayed with a
--cache-stats
flag - Implement cache size limits to prevent excessive memory usage
- Add a
--no-cache
flag to bypass caching when needed
Note: This is particularly useful for batch checking where multiple passwords might share the same prefix.
Bonus: Implement persistent caching using a file-based cache that survives application restarts.