DEV Community

Cover image for How to Securely Store Passwords in Databases
thinhda
thinhda

Posted on

How to Securely Store Passwords in Databases

In today's digital age, securing user passwords is more important than ever. This article will guide you through the essentials of password security, ensuring that you understand why it's crucial to store passwords securely in databases. We'll start by discussing common threats like data breaches and the consequences of poor password management. Next, we'll highlight common mistakes developers make, such as storing passwords in plain text or using weak hashing algorithms. From there, we'll delve into the importance of hashing and salting passwords, explaining how these techniques work and why they are essential for security. We will also introduce modern, secure hashing algorithms like bcrypt, scrypt, and Argon2, and provide a step-by-step guide to implementing secure password storage in your application. Additionally, we'll cover other crucial security measures, such as enforcing strong password policies and using multi-factor authentication. Finally, we'll wrap up with a summary and a checklist of best practices to ensure your application's password storage remains secure in the ever-evolving landscape of cybersecurity.

Introduction to Password Security

In today's digital age, securing user passwords is more important than ever. With the increasing number of data breaches and cyber-attacks, ensuring that passwords are stored securely in databases is critical to protecting user information and maintaining the integrity of your systems. This section will introduce the basics of password security, explaining why it is crucial to store passwords securely in databases.

Why Password Security Matters

Passwords are often the first line of defense against unauthorized access to sensitive information. When passwords are not stored securely, they become vulnerable to a variety of threats, including:

  • Data Breaches: Cybercriminals often target databases to steal user credentials. If passwords are stored in plaintext or using weak hashing algorithms, attackers can easily gain access to user accounts and sensitive information.
  • Brute-Force Attacks: Attackers use automated tools to guess passwords by trying numerous combinations. Weak or unsalted passwords are particularly susceptible to these attacks.
  • Rainbow Table Attacks: These involve using precomputed tables of hash values to crack passwords. If passwords are not salted, attackers can use rainbow tables to quickly reverse-engineer the original passwords.

Consequences of Poor Password Management

Failing to secure passwords properly can have severe consequences, including:

  • Loss of Trust: Users expect their personal information to be protected. A data breach can erode trust and damage your organization's reputation.
  • Financial Loss: Data breaches can result in significant financial losses due to legal penalties, compensation to affected users, and costs associated with mitigating the breach.
  • Regulatory Penalties: Many jurisdictions have strict regulations regarding data protection. Non-compliance can result in hefty fines and legal action.

By understanding the importance of password security, you can better appreciate the methods and best practices we'll cover in the subsequent sections. These will include detailed explanations of hashing algorithms, salting, peppering, and additional security measures like Two-Factor Authentication (2FA) and robust password policies.

Stay tuned as we delve deeper into these topics to equip you with the knowledge and tools needed to secure passwords effectively and protect your users' data.

Common Mistakes in Password Storage

Before diving into best practices, it's essential to recognize the common mistakes that developers make when storing passwords. Awareness of these pitfalls will help you avoid them and set the stage for implementing more secure methods.

Let's explore some of the most prevalent mistakes:

1. Storing Passwords in Plain Text

One of the most egregious mistakes is storing passwords in plain text. This practice leaves passwords vulnerable to anyone who gains access to the database. If an attacker breaches your database, they can see all user passwords in their original form, leading to severe security breaches.

thinhda-plaintext

2. Using Weak Hashing Algorithms

Another common mistake is using weak or outdated hashing algorithms such as MD5 or SHA-1. These algorithms are fast, but that speed is a double-edged sword. It makes them susceptible to brute-force attacks and rainbow table attacks. Modern attackers can crack these hashes quickly using specialized hardware.

3. Neglecting to Use Salts

Salts are random values added to passwords before hashing to ensure that identical passwords produce different hashes. Neglecting to use salts means that identical passwords will have identical hashes, making it easier for attackers to crack multiple passwords at once using precomputed rainbow tables.

import hashlib

## Example of hashing without salt (not recommended)
password = "user_password"
hashed_password = hashlib.sha256(password.encode()).hexdigest()
print(hashed_password)
Enter fullscreen mode Exit fullscreen mode

4. Using Predictable or Reused Salts

Even when salts are used, they must be unique and unpredictable. Using the same salt for every password or generating salts in a predictable manner undermines their effectiveness. Attackers can exploit these patterns to crack passwords more efficiently.

5. Inadequate Password Policies

Weak password policies lead to weak passwords, which are easier to crack. Allowing users to set short, simple, or common passwords significantly reduces the security of your system. Implementing strong password policies is crucial to ensure that users create secure passwords.

6. Storing Salts Insecurely

thinhda_insecure_salt

Salts should be stored securely alongside the hashed passwords. If salts are stored insecurely or in a separate, easily accessible location, attackers can use them to crack passwords more easily. Ensuring that salts are stored securely is as important as using them in the first place.

7. Failing to Implement Multi-Factor Authentication (MFA)

While not directly related to password storage, failing to implement Multi-Factor Authentication (MFA) is a significant oversight. MFA provides an additional layer of security, making it much harder for attackers to gain access even if they manage to crack a password.

By understanding and avoiding these common mistakes, you can significantly enhance the security of your password storage mechanisms. In the next section, we'll delve into best practices for storing passwords securely, including the use of key stretching algorithms, strong password policies, and more.

Hashing Passwords

hashing_password.png

Hashing is a fundamental technique for securing passwords. In this section, we'll explain what hashing is and why it's a critical component of password security. We'll discuss different hashing algorithms like MD5, SHA-1, and SHA-256, highlighting their strengths and weaknesses. By the end of this section, you'll have a clear understanding of how hashing works and why it's preferable to storing passwords in plain text.

Understanding Hashing

Hashing is a process that transforms an input (or 'message') into a fixed-length string of characters, which is typically a hexadecimal number. This transformation is performed using a hash function. Hash functions are designed to be one-way functions, meaning that once data has been transformed into a hash, it cannot be feasibly reversed to retrieve the original input.

Here's a simple example in Python using the hashlib library:

import hashlib

## Original password
password = "securepassword123"

## Hashing the password using SHA-256
hashed_password = hashlib.sha256(password.encode()).hexdigest()

print(f"Original Password: {password}")
print(f"Hashed Password: {hashed_password}")
Enter fullscreen mode Exit fullscreen mode

Why Hashing is Critical for Password Security

Storing passwords in plain text is a significant security risk. If an attacker gains access to the database, they can see all user passwords directly. Hashing mitigates this risk by storing the hashed version of the password instead. Even if the database is compromised, the attacker would only obtain the hashed passwords, which are not easily reversible.

Common Hashing Algorithms

MD5

MD5 (Message Digest Algorithm 5) produces a 128-bit hash value. It was widely used in the past but is now considered insecure due to vulnerabilities that allow for hash collisions.

hashed_password_md5 = hashlib.md5(password.encode()).hexdigest()
print(f"MD5 Hashed Password: {hashed_password_md5}")
Enter fullscreen mode Exit fullscreen mode
SHA-1

SHA-1 (Secure Hash Algorithm 1) produces a 160-bit hash value. It was also widely used but has been found to have
vulnerabilities that make it less secure than newer algorithms.

hashed_password_sha1 = hashlib.sha1(password.encode()).hexdigest()
print(f"SHA-1 Hashed Password: {hashed_password_sha1}")
Enter fullscreen mode Exit fullscreen mode
SHA-256

SHA-256 (Secure Hash Algorithm 256) is part of the SHA-2 family and produces a 256-bit hash value. It is currently considered secure and is widely used for cryptographic applications.

hashed_password_sha256 = hashlib.sha256(password.encode()).hexdigest()
print(f"SHA-256 Hashed Password: {hashed_password_sha256}")
Enter fullscreen mode Exit fullscreen mode

Limitations of Basic Hashing Algorithms

While MD5, SHA-1, and SHA-256 provide a level of security, they are not specifically designed for hashing passwords. They are fast and efficient, which makes them susceptible to brute-force attacks where an attacker tries many different passwords in quick succession.

To address these limitations, it is recommended to use Key Derivation Functions (KDFs) like bcrypt, scrypt, and Argon2, which are designed to be computationally intensive and slow, making brute-force attacks more difficult. These KDFs also incorporate salting and configurable iterations to further enhance security.

In the next section, we'll dive deeper into the concepts of salting and peppering, and how they further secure hashed
passwords.

Salting Passwords

Salting is an additional layer of security that can significantly enhance password protection. But what exactly is a salt, and how does it work in conjunction with hashing? In this section, we'll dive into the concept of salting, its importance, and practical examples to illustrate how to implement it effectively.

What is a Salt?

A salt is a random value added to a password before it is hashed. The primary purpose of a salt is to ensure that even if two users have the same password, their hashed passwords will be different. This randomness makes it significantly harder for attackers to use precomputed tables (like rainbow tables) to crack passwords.

How Salts Work

thinhda_salting_password

When a user sets or changes their password, a unique salt is generated for that specific password. The salt is then concatenated with the user's password, and the combined string is hashed. Both the salt and the resulting hash are stored in the database. Here’s a simplified example in Python:

import os
import hashlib


def generate_salt(length=16):
    return os.urandom(length)


def hash_password(password, salt):
    return hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100000)


## Example usage
password = "securepassword123"
salt = generate_salt()
hashed_password = hash_password(password, salt)

print(f"Salt: {salt.hex()}")
print(f"Hashed Password: {hashed_password.hex()}")
Enter fullscreen mode Exit fullscreen mode

In this example, the os.urandom function generates a random salt, and the hashlib.pbkdf2_hmac function hashes the
password combined with the salt.

Importance of Unique Salts

Using unique salts for each password is crucial for several reasons:

  1. Prevents Rainbow Table Attacks: A rainbow table is a precomputed table of hashes for common passwords. By adding a unique salt to each password, the hash values become unique, rendering rainbow tables ineffective.
  2. Mitigates Brute-Force Attacks: Even if an attacker manages to obtain the hashes from the database, they would need to brute-force each hash individually, which is computationally expensive and time-consuming.
  3. Enhances Security Across Systems: If the same password is used across multiple systems, unique salts ensure that the hashes will be different, providing an additional layer of security.

Practical Examples

Let’s consider a real-world scenario where we implement salting in a web application. We’ll use the bcrypt library, which is widely recommended for password hashing due to its built-in salting mechanism and resistance to brute-force attacks.

import bcrypt


def hash_password(password):
    salt = bcrypt.gensalt()
    hashed = bcrypt.hashpw(password.encode(), salt)
    return hashed


def check_password(stored_password, provided_password):
    return bcrypt.checkpw(provided_password.encode(), stored_password)


## Example usage
password = "securepassword123"
hashed_password = hash_password(password)

print(f"Hashed Password: {hashed_password}")

## Verifying the password
is_correct = check_password(hashed_password, "securepassword123")
print(f"Password Match: {is_correct}")
Enter fullscreen mode Exit fullscreen mode

In this example, the bcrypt.gensalt function generates a salt, and the bcrypt.hashpw function hashes the password with the salt. The bcrypt.checkpw function is used to verify the password during login.

Salt Length and Complexity

The recommended length for salts is at least 16 bytes. This length provides sufficient randomness to protect against most attacks. The complexity of the salt should be such that it is generated using a cryptographically secure random number generator, ensuring it cannot be easily guessed or reproduced.

By incorporating unique salts, we can greatly enhance the security of stored passwords and protect against a wide range of attacks. In the next section, we will explore the concept of peppering and how it can provide an additional layer of security.

Modern Password Hashing Algorithms

thinhda_modern_password

Not all hashing algorithms are created equal. This section will introduce modern, secure hashing algorithms such as bcrypt, scrypt, and Argon2. We'll explore the features that make these algorithms more secure, including their resistance to brute-force attacks and their ability to adapt to increasing computational power. By understanding these modern algorithms, you'll be better equipped to choose the right one for your application.

bcrypt

bcrypt is one of the most widely used hashing algorithms for passwords. It was designed to be computationally expensive to slow down brute-force attacks. Here are some of its key features:

  • Built-in Salting: bcrypt automatically generates a unique salt for each password, which is stored alongside the hash.
  • Adjustable Cost Factor: The cost factor determines the number of hashing rounds. Increasing the cost factor makes the hashing process more time-consuming, thereby increasing security.
  • Resistance to Brute-force Attacks: The computational expense makes it difficult for attackers to perform large-scale brute-force attacks.

Here’s an example of how to use bcrypt in Python:

import bcrypt


def hash_password(password):
    salt = bcrypt.gensalt()
    hashed = bcrypt.hashpw(password.encode(), salt)
    return hashed


def check_password(stored_password, provided_password):
    return bcrypt.checkpw(provided_password.encode(), stored_password)


## Example usage
password = "securepassword123"
hashed_password = hash_password(password)

print(f"Hashed Password: {hashed_password}")

## Verifying the password
is_correct = check_password(hashed_password, "securepassword123")
print(f"Password Match: {is_correct}")
Enter fullscreen mode Exit fullscreen mode

scrypt

scrypt is designed to be both memory and CPU-intensive, making it resistant to hardware attacks that use specialized hardware like FPGAs and ASICs. Here are its notable features:

  • Memory-Intensive: scrypt requires a significant amount of memory, making it difficult to perform parallel attacks using hardware accelerators.
  • Adjustable Parameters: You can fine-tune the CPU/memory cost, block size, and parallelization factor to balance security and performance.
  • High Security: scrypt is highly resistant to brute-force attacks due to its computational and memory requirements.

Here’s an example of how to use scrypt in Python:

import os
import hashlib


def hash_password(password, salt=None):
    if salt is None:
        salt = os.urandom(16)
    hashed = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
    return salt, hashed


def check_password(stored_password, stored_salt, provided_password):
    hashed = hashlib.scrypt(provided_password.encode(), salt=stored_salt, n=16384, r=8, p=1)
    return hashed == stored_password


## Example usage
password = "securepassword123"
salt, hashed_password = hash_password(password)

print(f"Salt: {salt.hex()}")
print(f"Hashed Password: {hashed_password.hex()}")

## Verifying the password
is_correct = check_password(hashed_password, salt, "securepassword123")
print(f"Password Match: {is_correct}")
Enter fullscreen mode Exit fullscreen mode

Argon2

Argon2 is the winner of the Password Hashing Competition (PHC) and is considered the most secure password hashing algorithm available today. It has two main variants, Argon2d and Argon2i, each optimized for different security needs. Here are its features:

  • Highly Configurable: Argon2 allows you to adjust the time cost, memory cost, and parallelism to balance between security and performance.
  • Resistance to Side-Channel Attacks: Argon2i is designed to be resistant to side-channel attacks, while Argon2d is optimized to resist GPU-based attacks.
  • State-of-the-Art Security: Argon2 incorporates the latest advancements in cryptography to provide robust security against various types of attacks.

Here’s an example of how to use Argon2 in Python:

from argon2 import PasswordHasher

ph = PasswordHasher()


def hash_password(password):
    hashed = ph.hash(password)
    return hashed


def check_password(stored_password, provided_password):
    try:
        ph.verify(stored_password, provided_password)
        return True
    except:
        return False


## Example usage
password = "securepassword123"
hashed_password = hash_password(password)

print(f"Hashed Password: {hashed_password}")

## Verifying the password
is_correct = check_password(hashed_password, "securepassword123")
print(f"Password Match: {is_correct}")
Enter fullscreen mode Exit fullscreen mode

By leveraging modern password hashing algorithms like bcrypt, scrypt, and Argon2, you can significantly enhance the security of stored passwords. Each algorithm offers unique features and configurations that can be tailored to meet your specific security requirements. In the next section, we will explore the concept of peppering and how it can provide an additional layer of security.

Implementing Secure Password Storage

In this section, we'll provide a step-by-step guide to implementing secure password storage in your application. We'll cover best practices for integrating hashing and salting, choosing the right algorithms, and ensuring that your implementation is robust against common vulnerabilities. Code snippets and practical examples will help you apply these techniques in real-world scenarios.

Step 1: Choosing the Right Hashing Algorithm

Selecting the right hashing algorithm is crucial for secure password storage. As discussed earlier, bcrypt, scrypt, and Argon2 are popular choices due to their robustness and configurability. Here’s a brief recap:

  • bcrypt: Known for its adaptive nature, bcrypt allows you to increase the cost factor to make it more computationally expensive over time.
  • scrypt: Memory-hard algorithm that makes parallel attacks difficult.
  • Argon2: Winner of the Password Hashing Competition (PHC), offering the highest security with configurable parameters.

Step 2: Implementing Hashing and Salting

Salting is the process of adding a unique value to each password before hashing it. This ensures that even if two users have the same password, their hashed values will be different. Here’s how you can implement hashing and salting using bcrypt:

import bcrypt


def hash_password(password):
    # Generate a salt
    salt = bcrypt.gensalt()
    # Hash the password with the generated salt
    hashed = bcrypt.hashpw(password.encode(), salt)
    return hashed


def check_password(stored_password, provided_password):
    # Check if the provided password matches the stored password
    return bcrypt.checkpw(provided_password.encode(), stored_password)


## Example usage
password = "securepassword123"
hashed_password = hash_password(password)

print(f"Hashed Password: {hashed_password}")

## Verifying the password
is_correct = check_password(hashed_password, "securepassword123")
print(f"Password Match: {is_correct}")
Enter fullscreen mode Exit fullscreen mode

Step 3: Adjusting Cost Factors

For each algorithm, you can adjust cost factors to balance security and performance. Here’s how you can configure these for bcrypt, scrypt, and Argon2:

Bcrypt

salt = bcrypt.gensalt(rounds=12)  # Increase the rounds to make it more secure
hashed = bcrypt.hashpw(password.encode(), salt)
Enter fullscreen mode Exit fullscreen mode

Scrypt

import hashlib


def hash_password(password, salt=None):
    if salt is None:
        salt = os.urandom(16)
    hashed = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
    return salt, hashed
Enter fullscreen mode Exit fullscreen mode

Argon2

from argon2 import PasswordHasher

ph = PasswordHasher(time_cost=3, memory_cost=102400, parallelism=8)  # Adjust the parameters as needed


def hash_password(password):
    hashed = ph.hash(password)
    return hashed
Enter fullscreen mode Exit fullscreen mode

Step 4: Handling Password Resets

Securely handling password resets is crucial to prevent unauthorized access. Here’s a basic implementation using temporary tokens:

import secrets
import time


def generate_reset_token():
    token = secrets.token_urlsafe()
    expiration_time = time.time() + 3600  # Token valid for 1 hour
    return token, expiration_time


def verify_reset_token(token, expiration_time):
    if time.time() > expiration_time:
        return False
    # Add additional checks as needed
    return True


## Example usage
reset_token, expiration = generate_reset_token()
print(f"Reset Token: {reset_token}, Expires at: {time.ctime(expiration)}")

## Verifying the token
is_valid = verify_reset_token(reset_token, expiration)
print(f"Token Valid: {is_valid}")
Enter fullscreen mode Exit fullscreen mode

Step 5: Rate Limiting and Account Lockout

Implementing rate limiting and account lockout mechanisms helps defend against brute force attacks. Here’s a simple example using a dictionary to track login attempts:

from collections import defaultdict
import time

login_attempts = defaultdict(list)


def rate_limit(username):
    now = time.time()
    # Remove attempts older than 15 minutes
    login_attempts[username] = [t for t in login_attempts[username] if now - t < 900]
    if len(login_attempts[username]) >= 5:
        return False
    login_attempts[username].append(now)
    return True


## Example usage
username = "user1"
if rate_limit(username):
    print("Login attempt allowed")
else:
    print("Account locked due to too many failed attempts")
Enter fullscreen mode Exit fullscreen mode

Step 6: Integrating Multi-Factor Authentication (MFA)

thinhda_mfa

Enhancing security with Multi-Factor Authentication (MFA) can significantly reduce the risk of unauthorized access. Here’s a basic example using the pyotp library for Time-based One-Time Passwords (TOTP):

import pyotp

## Generate a TOTP secret key
totp_secret = pyotp.random_base32()
print(f"TOTP Secret: {totp_secret}")

## Generate a TOTP code
totp = pyotp.TOTP(totp_secret)
code = totp.now()
print(f"TOTP Code: {code}")

## Verify the TOTP code
is_valid = totp.verify(code)
print(f"TOTP Code Valid: {is_valid}")
Enter fullscreen mode Exit fullscreen mode

By following these steps, you can implement secure password storage in your application, protecting user credentials from common vulnerabilities and enhancing overall security.

Additional Security Measures

While hashing and salting are crucial, they are not the only measures you should take to secure passwords. This section will discuss additional security practices such as enforcing strong password policies, using multi-factor authentication, and regularly updating your security protocols. By adopting a holistic approach to password security, you can better protect your users and your application.

Enforcing Strong Password Policies

One of the first steps in securing passwords is to enforce strong password policies. Weak passwords are a common vulnerability that can be easily exploited. Here are some guidelines to enforce strong password policies:

  1. Minimum Length: Require passwords to be at least 12 characters long.
  2. Complexity: Ensure passwords include a mix of uppercase letters, lowercase letters, numbers, and special characters.
  3. No Common Passwords: Prevent the use of common passwords such as "password123" or "admin".
  4. Password Expiration: Consider implementing a policy where passwords must be changed every 90 days.
  5. No Reuse: Prevent users from reusing their last 5 passwords.

Here's a simple Python function to validate password strength:

import re


def validate_password(password):
    if len(password) < 12:
        return False, "Password must be at least 12 characters long"
    if not re.search("[a-z]", password):
        return False, "Password must contain at least one lowercase letter"
    if not re.search("[A-Z]", password):
        return False, "Password must contain at least one uppercase letter"
    if not re.search("[0-9]", password):
        return False, "Password must contain at least one digit"
    if not re.search("[@#$%^&+=]", password):
        return False, "Password must contain at least one special character"
    return True, "Password is valid"


## Example usage
password = "SecurePassw0rd!"
is_valid, message = validate_password(password)
print(message)
Enter fullscreen mode Exit fullscreen mode

Multi-Factor Authentication (MFA)

Enhancing security with Multi-Factor Authentication (MFA) can significantly reduce the risk of unauthorized access. MFA requires users to provide two or more verification factors to gain access to an account, adding an extra layer of security.

Time-based One-Time Passwords (TOTP)

One common method of MFA is using Time-based One-Time Passwords (TOTP). Here’s a basic example using the pyotp library for TOTP:

import pyotp

## Generate a TOTP secret key
totp_secret = pyotp.random_base32()
print(f"TOTP Secret: {totp_secret}")

## Generate a TOTP code
totp = pyotp.TOTP(totp_secret)
code = totp.now()
print(f"TOTP Code: {code}")

## Verify the TOTP code
is_valid = totp.verify(code)
print(f"TOTP Code Valid: {is_valid}")
Enter fullscreen mode Exit fullscreen mode

Regularly Updating Security Protocols

Security is an ever-evolving field, and it is crucial to stay updated with the latest security practices and protocols. Regularly updating your security measures can help protect against new vulnerabilities and threats.

  1. Patch Management: Regularly update your software and libraries to patch known vulnerabilities.
  2. Security Audits: Conduct regular security audits to identify and fix potential security issues.
  3. User Education: Educate your users about the importance of security and how to recognize phishing attempts and other common attacks.

By implementing these additional security measures, you can create a more secure environment for your users and significantly reduce the risk of unauthorized access and data breaches.

Best Practices Checklist

thinhda_checklist

  • [ ] Hash passwords using bcrypt, Argon2, or PBKDF2.
  • [ ] Add a unique salt to each password before hashing.
  • [ ] Enforce strong password policies (minimum length, complexity, no reuse).
  • [ ] Implement Multi-Factor Authentication (MFA) using TOTP.
  • [ ] Use HTTPS to encrypt data in transit.
  • [ ] Clear passwords from memory once they are no longer needed.
  • [ ] Implement rate limiting on login endpoints.
  • [ ] Use account lockout mechanisms after multiple failed login attempts.
  • [ ] Implement secure password recovery mechanisms.
  • [ ] Regularly update security protocols, conduct security audits, and educate users.

By following these best practices, you can ensure that your application remains secure in the ever-evolving landscape of cybersecurity. Stay vigilant, continuously improve your security measures, and keep up with the latest trends to protect your users and their data.

You can view more interesting articles here.

Top comments (0)