Every web application that stores passwords must hash them. This is not optional. Storing plaintext passwords is the most basic security failure, and it still happens -- LinkedIn lost 117 million hashed passwords in 2012, and many were cracked because they used unsalted SHA-1.
The problem is not that developers do not know about password hashing. The problem is that choosing and configuring a hashing algorithm requires expertise that most developers do not have. Should you use bcrypt or scrypt? What cost factor? How many iterations? What memory parameter? The answer depends on your hardware, your threat model, and the current year.
FLIN makes this decision for you. The hash_password() function uses Argon2id with tuned parameters. The verify_password() function performs timing-safe comparison. There is no configuration, no algorithm selection, and no way to accidentally use MD5.
Why Argon2id
Argon2 won the Password Hashing Competition (PHC) in 2015. It was designed specifically for password hashing, unlike bcrypt (1999, designed for general-purpose hashing) and scrypt (2009, designed for key derivation).
Argon2 comes in three variants:
- Argon2d -- optimized against GPU cracking, vulnerable to side-channel attacks
- Argon2i -- optimized against side-channel attacks, weaker against GPU cracking
- Argon2id -- hybrid, combines the strengths of both
FLIN uses Argon2id because it provides the best overall protection. OWASP recommends it as the first choice for password hashing. The IETF standardized it in RFC 9106.
Two Functions, Zero Configuration
The FLIN developer interface is two functions:
flin// Hash a password
hash = hash_password("correct horse battery staple")
// Returns: "$argon2id$v=19$m=65536,t=3,p=4$..."
// Verify a password
is_valid = verify_password("correct horse battery staple", hash)
// Returns: true or falseThat is the entire API. No algorithm selection. No parameter tuning. No salt generation. No encoding format decisions. The function handles everything internally.
The Default Parameters
FLIN's Argon2id implementation uses these parameters:
rustconst ARGON2_MEMORY: u32 = 65536; // 64 MB
const ARGON2_ITERATIONS: u32 = 3; // 3 passes
const ARGON2_PARALLELISM: u32 = 4; // 4 parallel threads
const ARGON2_OUTPUT_LEN: usize = 32; // 256-bit hash
const ARGON2_SALT_LEN: usize = 16; // 128-bit saltThese parameters are based on OWASP's 2024 recommendations for Argon2id:
| Parameter | Value | Purpose |
|---|---|---|
| Memory | 64 MB | Makes GPU/ASIC attacks expensive |
| Iterations | 3 | Increases computation time |
| Parallelism | 4 | Uses multiple CPU cores |
| Output length | 32 bytes | 256-bit hash |
| Salt length | 16 bytes | 128-bit random salt |
With these parameters, hashing one password takes approximately 200 milliseconds on a modern server. This is fast enough that users do not notice the delay during login, but slow enough that an attacker trying to crack a stolen database would need approximately 57 years of GPU time per password.
The Implementation
The Rust implementation in the FLIN runtime uses the argon2 crate with the following structure:
rustuse argon2::{Argon2, PasswordHasher, PasswordVerifier};
use argon2::password_hash::{SaltString, rand_core::OsRng};
pub fn hash_password(password: &str) -> Result<String, SecurityError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
argon2::Params::new(
ARGON2_MEMORY,
ARGON2_ITERATIONS,
ARGON2_PARALLELISM,
Some(ARGON2_OUTPUT_LEN),
)?,
);
let hash = argon2
.hash_password(password.as_bytes(), &salt)?
.to_string();
Ok(hash)
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool, SecurityError> {
let parsed_hash = argon2::PasswordHash::new(hash)
.map_err(|_| SecurityError::InvalidHash)?;
let argon2 = Argon2::default();
Ok(argon2.verify_password(password.as_bytes(), &parsed_hash).is_ok())
}Several security details are worth highlighting:
Random salt generation. Each password gets a unique 16-byte salt from the operating system's cryptographic random number generator (OsRng). Two users with the same password get different hashes. This defeats rainbow table attacks.
Timing-safe comparison. The verify_password function uses constant-time comparison internally. An attacker cannot determine how many characters of the password are correct by measuring response time.
Self-describing hash format. The output string $argon2id$v=19$m=65536,t=3,p=4$... encodes the algorithm, version, and all parameters. If we ever upgrade the default parameters, verify_password can still verify hashes created with the old parameters because the parameters are stored in the hash itself.
Custom Parameters
For applications with specific requirements, FLIN allows parameter customization:
flin// Higher security (slower, more memory)
hash = hash_password(password, {
algorithm: "argon2id",
memory: 131072, // 128 MB
iterations: 4,
parallelism: 8
})
// Lower resource usage (for constrained environments)
hash = hash_password(password, {
algorithm: "argon2id",
memory: 32768, // 32 MB
iterations: 3,
parallelism: 2
})
// bcrypt compatibility (for migrating legacy systems)
hash = hash_password(password, {
algorithm: "bcrypt",
cost: 12
})Custom parameters are intentionally harder to use than the default. The default is a function call with one argument. Custom parameters require an options object. This API design nudges developers toward the secure default.
Password Migration
When migrating from another framework, existing password hashes may use bcrypt, scrypt, or even plain MD5. FLIN supports transparent migration:
flinfn login(email, password) {
user = User.where(email == email).first
if user == none {
return error(401, "Invalid credentials")
}
// verify_password auto-detects the algorithm from the hash format
if !verify_password(password, user.password) {
return error(401, "Invalid credentials")
}
// If the hash uses an old algorithm, re-hash with Argon2id
if !user.password.starts_with("$argon2id$") {
user.password = hash_password(password)
save user
}
create_session(user)
}The verify_password function detects the algorithm from the hash format prefix ($2b$ for bcrypt, $argon2id$ for Argon2id) and uses the appropriate verification algorithm. The login function then re-hashes with Argon2id on successful authentication, gradually migrating all active users to the stronger algorithm.
What Developers Do Not Have to Think About
The list of security decisions that FLIN makes for the developer is long:
Salt generation. Every hash gets a cryptographically random salt. There is no hash_password(password, "my-salt") function. You cannot reuse salts. You cannot use predictable salts.
Algorithm selection. Argon2id is the default. You cannot accidentally use MD5, SHA-256, or plaintext. The bcrypt option exists only for legacy migration, not for new applications.
Parameter tuning. The default parameters are tuned for 2024-2026 hardware. They will be updated in future FLIN versions as computing power increases.
Encoding format. The PHC string format is used automatically. There is no confusion between raw bytes, hex encoding, and Base64 encoding.
Timing attacks. Comparison is always constant-time. There is no hash == stored_hash that leaks information through timing.
Error handling. If hashing fails (e.g., out of memory due to the 64 MB allocation), the error is explicit and the function never returns a partial or empty hash.
Benchmarks
On a standard server (4 cores, 8 GB RAM), the FLIN Argon2id implementation produces these numbers:
| Operation | Time | Memory |
|---|---|---|
| Hash (default params) | 190 ms | 64 MB peak |
| Verify (default params) | 180 ms | 64 MB peak |
| Hash (bcrypt compat, cost 12) | 250 ms | < 1 MB |
For a login endpoint, 190 ms of password verification is invisible to the user. The total request time (including database lookup, session creation, and response) is typically under 250 ms.
For a brute-force attacker, 190 ms per guess means approximately 5 guesses per second per CPU core. With a 10-character password containing lowercase letters, uppercase letters, and digits, the search space is 62^10 (approximately 8.4 x 10^17). At 5 guesses per second, exhaustive search would take 5.3 billion years.
The Real-World Pattern
Here is how password hashing appears in a complete FLIN authentication flow:
flin// Registration
route POST {
validate {
email: text @required @email
password: text @required @minLength(8)
name: text @required @minLength(2)
}
existing = User.where(email == body.email).first
if existing != none {
return error(409, "Email already registered")
}
user = User {
email: body.email,
password: hash_password(body.password),
name: body.name,
role: "user"
}
save user
create_session(user)
response { status: 201, body: user }
}One function call. One line. The password is hashed with Argon2id, salted with cryptographic randomness, and stored in a self-describing format that can be verified indefinitely. The developer does not need to know anything about cryptography to get this right.
That is the point. Security should not require expertise. It should be the default.
In the next article, we cover JWT authentication -- how FLIN creates and verifies JSON Web Tokens in three lines of code.
This is Part 107 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [106] Security by Design: OWASP Top 10 in the Language - [107] Argon2 Password Hashing Built Into FLIN (you are here) - [108] JWT Authentication in 3 Lines of FLIN - [109] Rate Limiting and Security Headers