04 · Security Best Practices¶
Most application-level security bugs aren't exotic — they're plaintext passwords, string-concatenated SQL, and predictable "random" tokens. This module covers the three fixes that matter most in an everyday Kotlin backend: password hashing, safe randomness, and parameterized queries (which Module 3 of Level 3 already used correctly, without calling out why it matters).
Password hashing with bcrypt¶
Never store plaintext passwords, and never use a fast general-purpose hash (MD5, SHA-256 alone) for them — fast hashes are exactly what makes brute-forcing leaked password databases practical. bcrypt is deliberately slow and bakes in a random salt automatically, so identical passwords never produce identical hashes.
import at.favre.lib.crypto.bcrypt.BCrypt
fun main() {
val password = "correct horse battery staple"
val hash = BCrypt.withDefaults().hashToString(12, password.toCharArray())
println("Hash: ${hash.take(29)}... (truncated, includes salt + cost factor)")
val correctCheck = BCrypt.verifyer().verify(password.toCharArray(), hash)
val wrongCheck = BCrypt.verifyer().verify("wrong password".toCharArray(), hash)
println("Correct password verifies: ${correctCheck.verified}")
println("Wrong password verifies: ${wrongCheck.verified}")
val hash2 = BCrypt.withDefaults().hashToString(12, password.toCharArray())
println("Same password, different hash each time: ${hash != hash2}")
}
Hash: $2a$12$Vn/ZLatLYAYU15Zbc2nxQu... (truncated, includes salt + cost factor)
Correct password verifies: true
Wrong password verifies: false
Same password, different hash each time: true
The 12 passed to hashToString is bcrypt's cost factor — each
increment roughly doubles the work required to compute a hash, which is
exactly the property that makes brute-force attacks slower. Storing the
hash string alone is enough to verify later logins; the salt is embedded
in it, so no separate salt column is needed.
Secure randomness¶
kotlin.random.Random and java.util.Random are not safe for
anything security-sensitive (session tokens, password reset codes, API
keys) — their output is predictable given enough samples or a known seed.
java.security.SecureRandom uses a cryptographically strong source.
import java.security.SecureRandom
val secureRandom = SecureRandom()
val tokenBytes = ByteArray(16)
secureRandom.nextBytes(tokenBytes)
val token = tokenBytes.joinToString("") { "%02x".format(it) }
println("Secure random token: $token (${token.length} hex chars)")
A 16-byte (128-bit) random token is effectively unguessable — the
important thing is using SecureRandom specifically, not the byte count
(which you can tune per use case).
Parameterized queries prevent SQL injection¶
Exposed's eq, like, and friends (used throughout
Level 3's database module)
build parameterized queries under the hood — user input is passed to
the JDBC driver as a bound parameter, never spliced into the SQL string.
This makes classic SQL injection structurally impossible through the DSL,
regardless of what characters the input contains.
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.dao.id.IntIdTable
object Users : IntIdTable() {
val username = varchar("username", 50)
}
fun main() {
Database.connect("jdbc:sqlite:file:secdemo?mode=memory&cache=shared", driver = "org.sqlite.JDBC")
transaction {
SchemaUtils.create(Users)
Users.insert { it[username] = "alice" }
Users.insert { it[username] = "bob" }
val maliciousInput = "alice' OR '1'='1"
val safeResult = Users.selectAll().where { Users.username eq maliciousInput }.count()
println("Parameterized query with injection-attempt input: $safeResult rows (expected 0)")
}
}
The classic injection payload alice' OR '1'='1 matches zero rows here —
Exposed treats it as a literal username to search for, not as SQL syntax.
The vulnerable equivalent would be building a raw string like "SELECT *
FROM Users WHERE username = '$maliciousInput'" and executing it directly;
once concatenated, the query becomes ... WHERE username = 'alice' OR
'1'='1', which matches every row. Never build SQL by string concatenation
with any value that came from a user, ever — not even "just for an admin
tool."
Kotlin-specific traps¶
String.hashCode()is not a cryptographic hash. It's fast, unsalted, and has documented collisions — never use it (or.hashCode()on any type) for anything resembling a password or security token.- Comparing secrets with
==can leak timing information. String comparison in the JVM short-circuits on the first differing character, which — in security-sensitive contexts like comparing a computed HMAC against a stored one — can be exploited via timing attacks; use a constant-time comparison (e.g.java.security.MessageDigest.isEqual(a, b)) for that specific case. Password verification doesn't need this, since bcrypt'sverifyfunction already does constant-time comparison internally. - Logging full request/response bodies can leak secrets. A
CallLoggingsetup (from Module 2) that logs raw bodies will happily log passwords, tokens, and PII sent in JSON — mask or exclude sensitive fields before logging in real services. data classtoString()includes every property by default — including apasswordortokenfield, if one exists on the class. Exclude sensitive fields fromtoString(override it manually, or keep secrets in a separate, non-data class) so a strayprintln(user)or log statement doesn't leak them.- Hardcoded secrets (like the JWT
SECRETconstant in Module 2) are fine for a demo, never for production — real secrets belong in environment variables or a secrets manager, never committed to source control.
How It Actually Works¶
bcrypt's slowness is structural, not a configuration knob layered on top of
a fast hash — it's built on the Blowfish cipher's key-setup routine
(EksBlowfishSetup), which was deliberately designed to be expensive to
initialize, and the cost factor controls how many times (2^cost) that
setup loop repeats before the actual hash is produced. This is fundamentally
different from "SHA-256 run in a loop N times" because Blowfish's key
schedule is memory-and-CPU-bound in a way that resists the two classic
brute-force accelerants: GPUs (which excel at simple, massively parallel
arithmetic like SHA-256 but are far less efficient at Blowfish's dependent,
lookup-table-heavy setup) and ASICs built for hashing. The embedded salt
isn't stored separately because bcrypt's output format
($2a$12$<22-char-salt><31-char-hash>) concatenates the algorithm version,
cost factor, salt, and hash into one self-describing string — verifyer()
parses that string to extract the exact same salt and cost factor used
originally, recomputes the hash with the candidate password, and does a
constant-time comparison against the stored one.
SecureRandom differs from kotlin.random.Random/java.util.Random at the
algorithm level, not just "how it's used": both Random implementations are
linear congruential generators (LCGs) or similar — fast, deterministic
formulas where each output is a mathematical function of the previous
internal state, meaning observing enough consecutive outputs lets an
attacker reconstruct the seed and predict every future value. SecureRandom
instead draws entropy from OS-level unpredictable sources (interrupt
timing, hardware noise, /dev/urandom on Linux) and runs it through a
cryptographically-vetted algorithm (commonly a SHA-1/SHA-256-based DRBG),
specifically engineered so that observing outputs gives no practical way to
predict the next one.
Exposed's parameterization traces to the JDBC layer underneath it:
Users.select { Users.username eq input } compiles the query with a literal
? placeholder in the SQL string sent to the database driver, and passes
input separately via PreparedStatement.setString(...) — the database
engine parses the SQL structure before the parameter value is substituted
in, so a value like ' OR '1'='1 is bound as a single literal string value
for comparison, never re-parsed as part of the SQL grammar. String
concatenation-based queries fail exactly because they skip this
structure-then-substitute separation, letting attacker input be parsed as
SQL syntax rather than treated as inert data.
Cheat sheet¶
| Concern | Do | Don't |
|---|---|---|
| Store a password | BCrypt.withDefaults().hashToString(cost, pw) |
Plaintext, or MD5/SHA-256 alone |
| Generate a token/secret | SecureRandom |
Random/kotlin.random.Random |
| Build a query with user input | Exposed DSL (eq, like), or JDBC PreparedStatement |
String concatenation into SQL |
| Compare a secret/HMAC | MessageDigest.isEqual(a, b) |
== / .contentEquals() |
| Log request data | Mask/exclude sensitive fields | Log raw bodies unconditionally |
Exercise¶
Write a small UserAccount class with a passwordHash: String field
(never a raw password), a companion fun register(username: String,
password: String): UserAccount that hashes the password with bcrypt
before storing it, and a fun login(password: String): Boolean instance
method that verifies against the stored hash. Add a custom toString()
that prints the username but never the hash, and demonstrate that logging
a UserAccount via println never exposes the hash.