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:
| Hashing | Encryption | |
|---|---|---|
| Direction | One-way (cannot reverse) | Two-way (can decrypt) |
| Purpose | Verify data matches | Protect data for later use |
| Use for passwords? | Yes | No |
| Example | bcrypt, Argon2, SHA-256 | AES, 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?
| Feature | bcrypt | Argon2 |
|---|---|---|
| Age | 1999 | 2015 |
| GPU resistance | Moderate | Strong (memory-hard) |
| Configurable memory | No | Yes |
| Industry support | Everywhere | Growing |
| Recommendation | Great default | Best 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 Rule | New NIST Rule |
|---|---|
| Require uppercase, lowercase, numbers, symbols | Do not force complexity rules |
| Change passwords every 90 days | Do not force periodic rotation |
| Minimum 8 characters | Minimum 8 characters (15+ recommended) |
| Security questions | Do 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:
httpOnlycookies (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
| Topic | Best Practice |
|---|---|
| Password storage | bcrypt (cost 12+) or Argon2id |
| Password rules | Minimum 8 chars, no forced complexity, check breached lists |
| Salt | Automatic with bcrypt/Argon2 — never roll your own |
| JWT signing | RS256 or EdDSA for distributed systems, HS256 for single server |
| JWT expiry | Access token: 15 min, Refresh token: 7 days |
| Token storage | httpOnly cookies, never localStorage |
| Rate limiting | Required on login and token endpoints |
| Algorithm validation | Always specify accepted algorithms |
Related Articles
- Security Tutorial #1: Web Security Basics — OWASP Top 10
- Security Tutorial #3: Authorization — RBAC, OAuth 2.0, OpenID Connect
- Security Tutorial #4: SQL Injection and XSS — How to Prevent Them
- Security Tutorial #5: HTTPS and TLS — How Encryption Works
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.