Skip to content

04 · Security Best Practices

Two problems show up in almost every production service: proving a user is who they claim to be (authentication) and proving a caller is allowed to do what they're asking (authorization). This module builds both from JDK primitives only — password hashing with PBKDF2 and signed tokens with HMAC — so the mechanics are visible before you reach for a library like bcrypt bindings or a JWT library that does the same thing pre-packaged.

Never store plain-text passwords

Storing "hunter2" directly means a single database leak exposes every user's real password. The fix is a salted, slow hash: PBKDF2 with a per-user random salt and a high iteration count, built entirely from javax.crypto:

import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import java.util.Base64

object Passwords:
  private val random = SecureRandom()

  def hash(password: String): String =
    val salt = new Array[Byte](16)
    random.nextBytes(salt)
    val spec = new PBEKeySpec(password.toCharArray, salt, 100000, 256)
    val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
    val hashBytes = factory.generateSecret(spec).getEncoded
    s"${Base64.getEncoder.encodeToString(salt)}:${Base64.getEncoder.encodeToString(hashBytes)}"

  def verify(password: String, stored: String): Boolean =
    val Array(saltB64, hashB64) = stored.split(":")
    val salt = Base64.getDecoder.decode(saltB64)
    val spec = new PBEKeySpec(password.toCharArray, salt, 100000, 256)
    val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
    val candidateHash = Base64.getEncoder.encodeToString(factory.generateSecret(spec).getEncoded)
    java.security.MessageDigest.isEqual(candidateHash.getBytes, hashB64.getBytes)

val hashed = Passwords.hash("hunter2")
println(Passwords.verify("hunter2", hashed))   // true
println(Passwords.verify("wrong", hashed))     // false

The salt is stored alongside the hash (it doesn't need to be secret, just unique per password) — its job is making two users with the same password produce different stored hashes, which defeats precomputed rainbow-table attacks. The 100,000 iterations are deliberate: PBKDF2 is designed to be slow, so brute-forcing a leaked hash costs an attacker real computation per guess, unlike a single fast SHA-256 of the raw password.

The trap: comparing hashes with ==

verify uses MessageDigest.isEqual, not hashA == hashB. A naive String comparison short-circuits at the first mismatched character, which means the comparison takes measurably longer the more characters match — a timing side-channel an attacker can exploit to guess a hash byte-by-byte. MessageDigest.isEqual always compares the full length in constant time regardless of where the strings diverge, specifically to close that channel. This same "constant-time compare" concern applies to comparing any other secret (API keys, signatures) — never == on secrets that came from outside the process.

Signed tokens for stateless authentication

Once a password is verified, the server issues a token the client sends on later requests instead of re-authenticating every time. An HMAC-signed token (the mechanism a JWT is built on) proves the token wasn't tampered with, without the server needing to store session state:

import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.nio.charset.StandardCharsets

object Tokens:
  private val secret = "super-secret-key"   // in production: from a secrets manager, never hardcoded

  def sign(payload: String): String =
    val mac = Mac.getInstance("HmacSHA256")
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"))
    val sig = Base64.getUrlEncoder.withoutPadding.encodeToString(
      mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))
    )
    s"$payload.$sig"

  def verify(token: String): Option[String] =
    token.split("\\.", 2) match
      case Array(payload, sig) if sign(payload) == token => Some(payload)
      case _ => None

val token = Tokens.sign("user=ada;role=admin")
println(token)              // user=ada;role=admin.6poDkG3WB9DhDHRUKpM8L6s9SOqaM1xF-gqgZyPv56E
println(Tokens.verify(token))            // Some(user=ada;role=admin)
println(Tokens.verify(token + "tampered")) // None -- signature no longer matches

Anyone can read the payload (it's only Base64/plain text, not encrypted) — the signature only proves it wasn't modified since the server signed it, since recomputing a matching signature requires the secret key. This is exactly the guarantee a JWT's signature provides; a real JWT adds a standard JSON structure (header/claims), expiry (exp), and usually a library instead of hand-rolled parsing — but the cryptographic core is the same HMAC(secret, payload).

The trap: authentication proves identity, not permission

Verifying a token tells you who is calling — it says nothing about what they're allowed to do. Checking role=admin requires reading the verified payload and explicitly gating the operation:

def requireAdmin(payload: String): Boolean = payload.contains("role=admin")

Tokens.verify(token).foreach { payload =>
  if requireAdmin(payload) then println("access granted")
  else println("403 forbidden")
}
// access granted

Skipping this step — treating "the token is valid" as "the request is allowed" — is how services end up letting any logged-in user perform admin-only actions.

How It Actually Works

Password hashing algorithms built for this purpose (bcrypt, scrypt, Argon2) are deliberately slow and memory-hard at the CPU/hardware level — bcrypt, for instance, runs a configurable number of rounds of a Blowfish-derived key schedule specifically designed to resist GPU/ASIC-based brute-force parallelization, unlike a general-purpose hash like SHA-256 which is fast by design (great for checksums, terrible for password storage, since fast hashing is exactly what makes brute-forcing cheap). Each hash also embeds a random salt generated per password, which is mixed into the hashing computation — this is the mechanical reason two users with the identical password get completely different stored hashes, defeating precomputed rainbow-table lookups.

Comparing hashes with plain == is a real vulnerability because == on byte arrays or strings in most runtimes short-circuits at the first differing byte — an attacker measuring response time across many guesses can statistically infer how many leading bytes matched, incrementally reconstructing the hash. A constant-time comparison function walks every byte regardless of where a mismatch occurs (typically by XOR-ing corresponding bytes and OR-ing the results into an accumulator, only checking "was the accumulator ever nonzero" at the very end) so the comparison takes the same wall-clock time whether the first byte differs or the last one does.

A signed token (JWT) is three base64url-encoded segments — header, payload (claims), and a signature — where the signature is an HMAC or RSA/ECDSA computation over the first two segments using a secret or private key only the server holds. Verifying a token means recomputing that same signature over the received header+payload and comparing it (constant-time, per above) against the signature segment the client sent — since forging a valid signature without the key is computationally infeasible, this is what makes the payload trustworthy without the server needing to store any session state per token. The "authentication proves identity, not permission" trap is a design fact independent of this mechanism: a validly-signed token proves who signed off on these claims (the server, when it issued the token to a specific user) — whether that user is allowed to perform a given action is a separate authorization check the application must still perform against the claims, since nothing about signature validity implies permission.

Cheat sheet

Need to... Use
Store a password safely PBKDF2 (or bcrypt/argon2 in production) with a per-user random salt
Compare secrets/hashes MessageDigest.isEqual, never ==
Issue a stateless auth token HMAC-signed payload (the mechanism behind JWT)
Verify a token wasn't tampered with recompute the signature and compare (constant-time)
Gate an action by role/permission check the verified payload's claims explicitly — authn ≠ authz

Exercise

Add an expiry to Tokens: change sign to embed a Unix timestamp in the payload (e.g. s"user=ada;role=admin;exp=${System.currentTimeMillis() + 60000}"), and change verify to return None if the signature is valid but exp has already passed. Test that a freshly signed token verifies successfully, and that a token you construct with an exp in the past (even with a correct signature) is rejected. Then write hasRole(payload: String, role: String): Boolean generically (don't hardcode "admin") and use it to gate two different pretend endpoints requiring different roles.