Back to flin
flin

Security Functions: Crypto, JWT, Argon2

How FLIN ships production-grade security functions as built-ins -- password hashing with Argon2, JWT signing and verification, HMAC, encryption, and secure random generation.

Thales & Claude | March 25, 2026 10 min flin
flinsecuritycryptojwtargon2

Security is the one area where "just install a package" is genuinely dangerous. Every year, compromised npm packages steal credentials. Outdated dependencies expose known vulnerabilities. Developers copy SHA-256 password hashing from Stack Overflow, not knowing that SHA-256 is catastrophically wrong for passwords. Security requires correct defaults, and correct defaults require the language itself to provide them.

In Sessions 183 and 184, we built FLIN's security functions: password hashing with Argon2, JWT creation and verification, HMAC signatures, symmetric encryption, and cryptographically secure random generation. All built-in. All using the correct algorithms by default. All making the secure path the easy path.

Password Hashing: Argon2 by Default

The single most important security function in any web application is password hashing. Get it wrong, and a database breach exposes every user's password in plaintext. Get it right, and a breach reveals nothing useful.

FLIN uses Argon2id -- the current winner of the Password Hashing Competition and the recommended algorithm by OWASP -- as the only built-in password hashing function:

// Hash a password (one function call)
hashed = hash_password("user_password_here")
// "$argon2id$v=19$m=19456,t=2,p=1$random_salt$hash_output"

// Verify a password against a hash is_valid = verify_password("user_password_here", hashed) // true ```

Two functions. That is the entire password API. No algorithm selection. No parameter tuning. No salt generation. FLIN handles all of it with secure defaults.

The hash output is a single string in the standard PHC format, containing the algorithm identifier, version, parameters, salt, and hash. You store this string in your database. When verifying, you pass the plaintext password and the stored hash. The function extracts the parameters and salt from the hash string and recomputes. If they match, it returns true.

Why No Algorithm Choice

Most password hashing libraries let you choose between bcrypt, scrypt, PBKDF2, and Argon2. This is a footgun. A developer who does not understand the differences might choose PBKDF2 (the weakest option) because it has the simplest name, or bcrypt (adequate but outdated) because a 2015 blog post recommended it.

FLIN removes the choice. You get Argon2id. It is the best algorithm available. The parameters are tuned for a balance between security and performance suitable for web applications:

  • Memory: 19,456 KiB (19 MB)
  • Iterations: 2
  • Parallelism: 1

These parameters produce a hash in approximately 100-200 milliseconds on a modern server. Fast enough that users do not notice a delay during login. Slow enough that an attacker brute-forcing stolen hashes would need years per password.

The Deliberate Absence of SHA-256

FLIN does not provide a sha256_password or md5_password function. This is deliberate. SHA-256 and MD5 are cryptographic hash functions designed for integrity verification, not password storage. They are fast by design -- billions of hashes per second on a GPU. Using them for passwords is a security vulnerability, not a feature.

If a developer truly needs SHA-256 for non-password purposes (verifying file integrity, computing checksums), it is available as a general-purpose hash function:

// General-purpose hashing (NOT for passwords)
checksum = sha256("file contents here")
// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

digest = sha512("data to hash") ```

But hash_password and verify_password -- the functions whose names a developer will search for when implementing authentication -- always use Argon2id. The naming itself guides developers toward the correct choice.

JWT: Token Creation and Verification

JSON Web Tokens are the standard for API authentication in modern web applications. FLIN includes built-in JWT support:

// Create a JWT
token = jwt_sign({
    user_id: 42,
    role: "admin",
    email: "[email protected]"
}, env("JWT_SECRET"), {
    expires_in: 30.days
})
// "eyJhbGciOiJIUzI1NiIs..."

// Verify and decode a JWT claims = jwt_verify(token, env("JWT_SECRET")) {if claims != none} print("User: {claims['user_id']}, Role: {claims['role']}") {else} print("Invalid or expired token") {/if} ```

jwt_sign takes three arguments: a claims map, a secret key, and options. The claims map can contain any data you want to embed in the token. The secret key is used for HMAC-SHA256 signing (the most common JWT algorithm for single-server applications). The options map supports expires_in (a duration) and issuer (a string).

jwt_verify returns the decoded claims map if the token is valid and not expired, or none if verification fails for any reason (invalid signature, expired, malformed). This is another instance of FLIN's "return none instead of throwing" philosophy -- the developer checks for none instead of wrapping every verification in a try-catch.

JWT Options

token = jwt_sign(claims, secret, {
    expires_in: 1.hour,        // Token expiration
    issuer: "flin.dev",        // iss claim
    audience: "api.flin.dev",  // aud claim
    not_before: now + 5.minutes // nbf claim (token not valid before)
})

Standard JWT claims (exp, iss, aud, nbf, iat) are set automatically from the options. iat (issued at) is always set to the current time. exp is set based on expires_in. The developer never needs to manually compute Unix timestamps for these claims.

Algorithm Selection

The default algorithm is HS256 (HMAC-SHA256), which is appropriate for applications where the same server creates and verifies tokens. For applications that need asymmetric signing (one server creates tokens, multiple servers verify them), FLIN supports RS256:

// Asymmetric JWT (RS256)
token = jwt_sign(claims, env("JWT_PRIVATE_KEY"), {
    algorithm: "RS256",
    expires_in: 1.hour
})

// Verify with public key claims = jwt_verify(token, env("JWT_PUBLIC_KEY"), { algorithm: "RS256" }) ```

HMAC Signatures

HMAC (Hash-based Message Authentication Code) is used for verifying that a message was not tampered with. It is the standard mechanism for webhook verification -- when Stripe or GitHub sends a webhook to your application, they include an HMAC signature that you verify against your shared secret.

// Sign a message
signature = hmac_sha256("message to sign", env("WEBHOOK_SECRET"))

// Verify a webhook signature expected = hmac_sha256(request_body, env("STRIPE_WEBHOOK_SECRET")) is_valid = secure_compare(expected, received_signature) ```

secure_compare is a constant-time string comparison function that prevents timing attacks. Regular string comparison (==) short-circuits on the first differing character, which leaks information about how much of the signature matched. secure_compare always compares every character, taking the same amount of time regardless of how many characters match. This is a subtle but critical security property.

Symmetric Encryption

For encrypting sensitive data at rest (database fields, configuration values, user data that must be stored securely), FLIN provides AES-256-GCM encryption:

// Encrypt
key = env("ENCRYPTION_KEY")  // 32-byte key
encrypted = encrypt("sensitive data here", key)

// Decrypt decrypted = decrypt(encrypted, key) print(decrypted) // "sensitive data here" ```

AES-256-GCM provides both confidentiality (the data is unreadable without the key) and authenticity (the data has not been tampered with). The initialization vector (IV/nonce) is generated randomly for each encryption and prepended to the ciphertext. The developer does not need to manage IVs manually.

The encrypted output is a base64-encoded string containing the IV and the ciphertext. You can store it in a database text column, send it as a JSON field, or write it to a file. Decryption extracts the IV automatically.

// Encrypting entity fields
entity User {
    name: text
    email: text
    ssn: text  // Sensitive!
}

// Store encrypted user = User.create( name: "Juste", email: "[email protected]", ssn: encrypt("123-45-6789", env("ENCRYPTION_KEY")) )

// Retrieve and decrypt raw_ssn = decrypt(user.ssn, env("ENCRYPTION_KEY")) ```

Secure Random Generation

Cryptographically secure random values are essential for tokens, session IDs, password reset codes, and API keys:

// Random bytes (base64 encoded)
token = random_bytes(32)
// "dGhpcyBpcyBhIHJhbmRvbSBieXRlIHN0cmluZw=="

// Random hex string api_key = random_hex(32) // "a1b2c3d4e5f6789012345678abcdef01"

// UUID v4 id = uuid() // "550e8400-e29b-41d4-a716-446655440000" ```

These functions use the operating system's cryptographic random number generator (/dev/urandom on Linux/macOS, BCryptGenRandom on Windows). They are safe for security-critical purposes like token generation. This is different from random() and random_int() in the math library, which use a PRNG that is fast but not suitable for security.

Hashing for Non-Password Purposes

General-purpose hashing is available for checksums, content addressing, and data deduplication:

sha256("hello")
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"

sha512("hello") // "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca..."

md5("hello") // "5d41402abc4b2a76b9719d911017c592" ```

MD5 is included despite being cryptographically broken because it is still widely used for non-security purposes (checksum verification, cache key generation, legacy API compatibility). It is explicitly documented as unsuitable for security.

Real-World Example: Complete Authentication Flow

Putting the security functions together, here is a complete authentication system:

entity User {
    email: text where is_email
    password_hash: text
    name: text
    created_at: time = now
}

// Registration fn register(email: text, password: text, name: text) { // Check if user exists existing = User.find_by(email: email) {if existing != none} return { error: "Email already registered" } {/if}

// Hash password and create user user = User.create( email: email, password_hash: hash_password(password), name: name )

// Generate JWT token = jwt_sign({ user_id: user.id, email: user.email, role: "user" }, env("JWT_SECRET"), { expires_in: 30.days })

return { user: user, token: token } }

// Login fn login(email: text, password: text) { user = User.find_by(email: email) {if user == none} return { error: "Invalid credentials" } {/if}

{if not verify_password(password, user.password_hash)} return { error: "Invalid credentials" } {/if}

token = jwt_sign({ user_id: user.id, email: user.email }, env("JWT_SECRET"), { expires_in: 30.days })

return { user: user, token: token } }

// Protected route fn get_profile(token: text) { claims = jwt_verify(token, env("JWT_SECRET")) {if claims == none} return { error: "Unauthorized" } {/if}

user = User.find(claims["user_id"]) return { user: user } } ```

Registration, login, and token-based authentication in 50 lines. Argon2 password hashing. JWT tokens with 30-day expiry. No security library imports. No middleware configuration. No authentication framework. The language provides the primitives. The developer composes them.

Implementation: Rust's Crypto Ecosystem

FLIN's security functions are built on Rust's battle-tested cryptography crates:

  • argon2 crate for password hashing
  • jsonwebtoken crate for JWT operations
  • aes-gcm crate for symmetric encryption
  • hmac and sha2 crates for HMAC and hashing
  • rand crate with OsRng for secure random generation

These crates are maintained by the RustCrypto project, one of the most active and security-focused open-source groups in the Rust ecosystem. They undergo regular security audits and follow Rust's memory safety guarantees, eliminating buffer overflow vulnerabilities that plague C-based crypto libraries.

fn builtin_hash_password(vm: &mut Vm, args: &[Value]) -> Result<Value, VmError> {
    let password = vm.get_string(args[0])?;

let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::new( Algorithm::Argon2id, Version::V0x13, Params::new(19_456, 2, 1, None).unwrap(), );

let hash = argon2 .hash_password(password.as_bytes(), &salt) .map_err(|e| VmError::CryptoError(e.to_string()))? .to_string();

Ok(vm.alloc_string_value(hash)) } ```

The implementation is straightforward because Rust's type system ensures the parameters are valid and the salt is properly generated. There is no possibility of forgetting to generate a salt, using a weak random source, or passing parameters in the wrong order. The compiler catches all of these at build time.

Eighteen Functions, Zero Vulnerabilities

The complete security API:

  • Password: hash_password, verify_password
  • JWT: jwt_sign, jwt_verify
  • HMAC: hmac_sha256, hmac_sha512
  • Encryption: encrypt, decrypt
  • Hashing: sha256, sha512, md5
  • Random: random_bytes, random_hex, uuid
  • Comparison: secure_compare
  • Encoding: base64_encode, base64_decode
  • Environment: env

Eighteen functions that replace bcrypt, jsonwebtoken, crypto-js, uuid, dotenv, and half a dozen other security-related packages. All using the correct algorithms by default. All making the secure path the easy path.

---

This is Part 76 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built production-grade security into a programming language.

Series Navigation: - [75] HTTP Client Built Into the Language - [76] Security Functions: Crypto, JWT, Argon2 (you are here) - [77] Introspection and Reflection at Runtime - [78] Reduce, Map, Filter: Higher-Order Functions

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles