In the previous tutorial, you learned the OWASP Top 10 security risks. Authentication failures (A07) are one of the most common. In this article, you will learn how to store passwords safely and implement token-based authentication with JWT.

Why Passwords Are Still the #1 Target

Despite all the advances in security, passwords remain the most common attack vector. Here is why:

  • People reuse passwords across websites
  • Weak passwords are easy to guess with brute force
  • Many applications still store passwords incorrectly

The LinkedIn breach in 2012 exposed 117 million passwords hashed with unsalted SHA-1. Attackers cracked most of them within days. The Adobe breach in 2013 exposed 153 million passwords encrypted (not hashed) with 3DES — all using the same key.

These breaches happened because developers did not understand password storage.

Never Store Passwords in Plaintext

This should be obvious, but it still happens. If your database stores passwords as password123, a single data breach exposes every user’s password.

Rule #1: Never store passwords in plaintext. Never.

Hashing vs Encryption

These are different things:

HashingEncryption
DirectionOne-way (cannot reverse)Two-way (can decrypt)
PurposeVerify data matchesProtect data for later use
Use for passwords?YesNo
Examplebcrypt, Argon2, SHA-256AES, RSA

For passwords, always use hashing. You never need to see the original password — you only need to verify that what the user typed matches what you stored.

Why MD5 and SHA-1 Are Not Enough

MD5 and SHA-1 are fast general-purpose hash functions. That speed is a problem for passwords:

  • MD5 can compute billions of hashes per second on modern GPUs
  • An attacker can try every possible 8-character password in hours
  • Rainbow tables (precomputed hash lookups) make it even faster

You need a hash function that is intentionally slow. That is what bcrypt and Argon2 are designed for.

Salt: Why It Matters

A salt is a random value added to each password before hashing. Without salt, two users with the password password123 would have the same hash. An attacker could crack one and know both.

With salt, every hash is unique — even for identical passwords.

Without salt:
  hash("password123") = "ef92b778bafe..."  (same for all users)

With salt:
  hash("password123" + "x7k9m2") = "a1b2c3d4..."
  hash("password123" + "p3q8r1") = "e5f6g7h8..."  (different!)

bcrypt and Argon2 generate and store the salt automatically. You do not need to manage it yourself.

bcrypt: The Gold Standard

bcrypt has been the standard for password hashing since 1999. It is slow by design, and you can increase the cost factor to make it slower as hardware gets faster.

bcrypt in Go

package main

import (
    "fmt"
    "golang.org/x/crypto/bcrypt"
)

func main() {
    password := []byte("mySecurePassword")

    // Hash the password (cost factor 12)
    hash, err := bcrypt.GenerateFromPassword(password, 12)
    if err != nil {
        panic(err)
    }
    fmt.Println("Hash:", string(hash))

    // Verify the password
    err = bcrypt.CompareHashAndPassword(hash, password)
    if err != nil {
        fmt.Println("Password does not match")
    } else {
        fmt.Println("Password matches")
    }
}

bcrypt in Python

import bcrypt

password = b"mySecurePassword"

# Hash the password
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
print("Hash:", hashed)

# Verify the password
if bcrypt.checkpw(password, hashed):
    print("Password matches")
else:
    print("Password does not match")

bcrypt in JavaScript (Node.js)

const bcrypt = require('bcrypt');

async function hashAndVerify() {
    const password = 'mySecurePassword';

    // Hash the password
    const hash = await bcrypt.hash(password, 12);
    console.log('Hash:', hash);

    // Verify the password
    const isValid = await bcrypt.compare(password, hash);
    console.log('Match:', isValid);
}

hashAndVerify();

The cost factor (12 in these examples) controls how slow the hashing is. Each increment doubles the time. A cost of 12 takes about 250ms on modern hardware — fast enough for users, too slow for brute force.

Argon2: The Modern Alternative

Argon2 won the Password Hashing Competition in 2015. It is the recommended choice for new applications. Unlike bcrypt, Argon2 is resistant to GPU-based attacks because it requires a configurable amount of memory.

Argon2 in Go

package main

import (
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "golang.org/x/crypto/argon2"
)

func main() {
    password := []byte("mySecurePassword")

    // Generate a random salt
    salt := make([]byte, 16)
    if _, err := rand.Read(salt); err != nil {
        panic(err)
    }

    // Hash with Argon2id (time=1, memory=64MB, threads=4, keyLen=32)
    hash := argon2.IDKey(password, salt, 1, 64*1024, 4, 32)

    fmt.Println("Salt:", hex.EncodeToString(salt))
    fmt.Println("Hash:", hex.EncodeToString(hash))
}

Argon2 in Python

from argon2 import PasswordHasher

ph = PasswordHasher()

# Hash the password
hashed = ph.hash("mySecurePassword")
print("Hash:", hashed)

# Verify the password
try:
    ph.verify(hashed, "mySecurePassword")
    print("Password matches")
except Exception:
    print("Password does not match")

bcrypt vs Argon2: Which Should You Use?

FeaturebcryptArgon2
Age19992015
GPU resistanceModerateStrong (memory-hard)
Configurable memoryNoYes
Industry supportEverywhereGrowing
RecommendationGreat defaultBest for new projects

Both are good choices. If you are starting a new project, use Argon2. If you already use bcrypt, there is no rush to switch.

NIST Password Guidelines (2024)

The National Institute of Standards and Technology updated their password guidelines. Some rules may surprise you:

Old RuleNew NIST Rule
Require uppercase, lowercase, numbers, symbolsDo not force complexity rules
Change passwords every 90 daysDo not force periodic rotation
Minimum 8 charactersMinimum 8 characters (15+ recommended)
Security questionsDo not use knowledge-based questions

NIST found that complexity rules make passwords worse. Users respond with patterns like Password1! that are easy to crack. Length is more important than complexity.

JWT: Token-Based Authentication

JSON Web Token (JWT) is the most common way to authenticate API requests. Instead of sessions stored on the server, the client holds a signed token.

How JWT Works

1. User logs in with username + password
2. Server verifies credentials
3. Server creates a JWT and sends it to the client
4. Client sends the JWT with every request (Authorization header)
5. Server validates the JWT signature and reads the claims

JWT Structure

A JWT has three parts, separated by dots:

header.payload.signature
  • Header: algorithm and token type ({"alg": "RS256", "typ": "JWT"})
  • Payload: claims — user data and metadata ({"sub": "123", "exp": 1700000000})
  • Signature: cryptographic signature that proves the token was not tampered with

The payload is Base64-encoded, not encrypted. Anyone can read it. Never put sensitive data (passwords, credit card numbers) in the payload.

JWT in Go

package main

import (
    "fmt"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

var secretKey = []byte("your-secret-key") // Use env variable in production

func createToken(userID string) (string, error) {
    claims := jwt.MapClaims{
        "sub": userID,
        "exp": time.Now().Add(15 * time.Minute).Unix(),
        "iat": time.Now().Unix(),
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(secretKey)
}

func validateToken(tokenString string) (*jwt.MapClaims, error) {
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        // Reject tokens that do not use the expected signing method
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return secretKey, nil
    })
    if err != nil {
        return nil, err
    }
    if !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    claims := token.Claims.(jwt.MapClaims)
    return &claims, nil
}

JWT in Python

import jwt
from datetime import datetime, timedelta, timezone

SECRET_KEY = "your-secret-key"  # Use env variable in production

def create_token(user_id: str) -> str:
    payload = {
        "sub": user_id,
        "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
        "iat": datetime.now(timezone.utc),
    }
    return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

def validate_token(token: str) -> dict:
    return jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

JWT in JavaScript (Node.js)

const jwt = require('jsonwebtoken');

const SECRET_KEY = process.env.JWT_SECRET; // Never hardcode

function createToken(userId) {
    return jwt.sign(
        { sub: userId },
        SECRET_KEY,
        { expiresIn: '15m' }
    );
}

function validateToken(token) {
    return jwt.verify(token, SECRET_KEY);
}

JWT Security Pitfalls

JWT is easy to use wrong. Here are the most common mistakes:

1. Storing Sensitive Data in the Payload

The JWT payload is Base64-encoded, not encrypted. Anyone can decode it:

echo "eyJzdWIiOiIxMjMifQ" | base64 -d
# Output: {"sub":"123"}

Never put passwords, credit card numbers, or secrets in the payload.

2. Not Validating the Signature

Always verify the token signature before trusting the claims. A missing or invalid signature means the token may have been tampered with.

3. The “none” Algorithm Attack

Some JWT libraries accept "alg": "none" — which means no signature at all. An attacker can forge tokens by setting the algorithm to none.

Fix: Always specify which algorithms you accept:

# Bad — accepts any algorithm
jwt.decode(token, SECRET_KEY)

# Good — only accept HS256
jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

4. Using Long-Lived Tokens

If a token is valid for 30 days and it gets stolen, the attacker has access for 30 days.

Fix: Use short-lived access tokens (15 minutes) with refresh tokens (7 days). When the access token expires, the client uses the refresh token to get a new one.

5. Storing Tokens in localStorage

localStorage is accessible from any JavaScript on the page. If your site has an XSS vulnerability, an attacker can steal the token.

Better options:

  • httpOnly cookies (JavaScript cannot access them)
  • In-memory storage (cleared on page refresh)

Access Token + Refresh Token Pattern

1. User logs in → server returns access token (15 min) + refresh token (7 days)
2. Client uses access token for API requests
3. Access token expires → client sends refresh token to /refresh endpoint
4. Server validates refresh token → issues new access token
5. Refresh token expires → user must log in again

This limits the damage if an access token is stolen. The attacker only has 15 minutes of access.

Prevention Checklist

TopicBest Practice
Password storagebcrypt (cost 12+) or Argon2id
Password rulesMinimum 8 chars, no forced complexity, check breached lists
SaltAutomatic with bcrypt/Argon2 — never roll your own
JWT signingRS256 or EdDSA for distributed systems, HS256 for single server
JWT expiryAccess token: 15 min, Refresh token: 7 days
Token storagehttpOnly cookies, never localStorage
Rate limitingRequired on login and token endpoints
Algorithm validationAlways specify accepted algorithms

What’s Next?

In the next tutorial, we will cover authorization — how to control what users can do after they log in. You will learn about Role-Based Access Control (RBAC), OAuth 2.0, and OpenID Connect.