The suprnova::hashing module is the framework's password hashing surface, with three first-class drivers - bcrypt (default, matches Laravel), Argon2i (memory-hard, side-channel-resistant), and Argon2id (OWASP 2024 recommendation). Use it when storing user passwords, hashing remember-me verifier tokens, or anywhere a one-way function is the right primitive. Driver selection is env-driven, and the facade is algorithm-aware end-to-end (info, is_hashed, needs_rehash, verify) so a stored bcrypt hash still verifies after you flip HASH_DRIVER=argon2id.
Overview
use hashing;
// Async (preferred inside Tokio request handlers - runs the CPU-bound
// hash on spawn_blocking so the worker thread stays free):
let hashed = hash_async.await?;
let valid = verify_async.await?;
// Sync (tests, CLI tools, non-async contexts):
let hashed = hash?;
let valid = verify?;
The async functions run on the blocking pool under one process-wide limit, HASH_MAX_CONCURRENCY, which Magnetar's sign-in hashing shares. One Argon2id hash under the defaults holds 64 MiB, so without a limit a burst of sign-ins could hold up to the pool's 512 threads and 32 GiB at once. With it, the burst holds at most the limit times one hash's memory, and the rest waits as tasks that hold no thread. A hash keeps its place under the limit until it returns, even when its caller stops waiting.
The free-function facade reads the active driver from HASH_DRIVER (or falls back to bcrypt). For explicit-driver calls, construct the driver type directly and pass it to hash_with / verify_with / needs_rehash_with.
Configuration
| Variable | Description | Default | Range |
|---|---|---|---|
HASH_DRIVER |
Active algorithm | bcrypt |
bcrypt | argon | argon2i | argon2id | a name registered with hashing::extend |
HASH_ROUNDS |
Bcrypt cost factor | 12 |
4..=31 (bcrypt only) |
HASH_MEMORY |
Argon memory cost in KiB | 65536 (64 MiB) |
>= 8 (argon only) |
HASH_TIME |
Argon time iterations | 4 |
>= 1 (argon only) |
ARGON_TIME |
Laravel's name for the Argon time, read when HASH_TIME is unset |
unset | >= 1 (argon only) |
HASH_THREADS |
Argon parallelism / lanes | 1 |
>= 1 (argon only) |
HASH_VERIFY |
When true, verify() rejects cross-algorithm hashes |
false |
true / false |
HASH_MAX_CONCURRENCY |
Most password hashes running at once in the process, the async functions and Magnetar together | the host's available parallelism | whole number >= 1; blank is unset |
Misconfiguration (bad value, out-of-range parameter, a HASH_DRIVER that names neither an algorithm nor a registered driver) surfaces as a FrameworkError::param at the first call to hash / verify / needs_rehash - not as a silent default.
A Laravel .env that sets ARGON_TIME keeps working: Suprnova reads it as the Argon time when HASH_TIME is unset, under the same minimum of 1. When both are set, HASH_TIME wins.
Example .env for argon2id
HASH_DRIVER=argon2id
HASH_MEMORY=65536
HASH_TIME=4
HASH_THREADS=1
Why Suprnova's Argon2 defaults are stronger than Laravel's
| Param | Laravel default | Suprnova default | Source |
|---|---|---|---|
| Memory | 1 024 KiB (1 MiB) | 65 536 KiB (64 MiB) | OWASP 2024 |
| Time | 2 iterations | 4 iterations | OWASP 2024 |
| Threads | 2 | 1 | OWASP 2024 / libsodium-aligned |
Laravel's defaults assume PHP's request-per-process model - a worker can only spend so much on each password hash before the box is full. Tokio's spawn_blocking lets Suprnova hand the hash off to a blocking thread pool without freezing the request loop, so the OWASP 2024 numbers are realistic on real production hardware.
Drivers
Bcrypt (default)
use ;
let driver = new;
let hashed = hash_with?;
assert!;
Bcrypt has a 72-byte block-size cap on the password input - the underlying primitive silently truncates longer inputs, which means two distinct passphrases sharing their first 72 bytes hash to the same value. Suprnova rejects an oversized password up-front when it hashes one: the framework's bcrypt path errors on hash(). verify() accepts a password of any length and judges it on its first 72 bytes, as PHP's password_verify does, so a user whose long password a Laravel application hashed still signs in. Argon2 has no such ceiling.
An application that shares its database with a Laravel application turns on LARAVEL_SHARED_DATABASE. Then hash() writes $2y$ bcrypt hashes, which Laravel's hasher accepts with HASH_VERIFY=true, and a valid sign-in rewrites a $2b$ or Argon2id hash as $2y$. See Running on a Laravel Database.
The bcrypt cap is exposed as suprnova::hashing::MAX_BCRYPT_PASSWORD_BYTES (71 - the usable limit after the bcrypt null terminator).
Argon2id (OWASP 2024 recommendation)
use ;
let driver = new?;
let hashed = hash_with?;
assert!;
// Argon2 accepts arbitrary-length passphrases - the bcrypt 72-byte cap
// doesn't apply.
let long = "x".repeat;
let h = hash_with?;
assert!;
Argon2i
Same shape as Argon2id; Argon2iHasher::new(opts). Use Argon2id for new projects - Argon2i is supported for parity but Argon2id is the modern recommendation.
Argon options for one call
Laravel passes options to one hash, as in Hash::make($password, ['time' => 3]). In Suprnova, build an Argon2idHasher from Argon2Options and hash with hash_with, as the Argon2id example above does. The options apply to that call only, under the minimums HASH_TIME, HASH_MEMORY, and HASH_THREADS have, and the configured driver stays as it is.
Registering a driver (extend)
hashing::extend(name, factory) registers a driver of your own under a name, and HASH_DRIVER=<name> selects it, as Laravel's Hash::extend does. The factory is a closure that returns a Box<dyn Hasher>. Use it for a peppered hasher, a hasher that wraps a key service, or an algorithm the framework doesn't ship.
use ;
// At boot, before the first hash. With `HASH_DRIVER=strong-bcrypt`, the
// facade hashes with this driver.
extend?;
let hashed = hash_async.await?;
The factory runs once, when the facade first resolves its driver, and only when HASH_DRIVER names it. It takes no arguments, so the algorithm settings in the environment don't reach it; read what your driver needs inside the closure. The cross-algorithm gate follows the driver's own verify_algorithm(), not HASH_VERIFY, and compares a stored hash's algorithm with the driver's algorithm(). verify still dispatches on the stored hash's algorithm, and hands a hash in no recognised algorithm to your driver.
extend returns an error and registers nothing when:
- The name is a built-in algorithm's (
bcrypt,argon,argon2i,argon2id, in any case), or blank, or has surrounding whitespace. - A driver is already registered under the name.
- The facade has already resolved its driver, through a hash, a verify, a rehash check, or
set_default_driver. A registration could no longer take effect.
HASH_DRIVER matches a registered name exactly, case included. A HASH_DRIVER that names neither a built-in algorithm nor a registered driver fails with FrameworkError::param; the facade never falls back to bcrypt. With HASH_DRIVER unset, the default driver stays bcrypt, whatever you register.
Why Suprnova diverges
Laravel's extend replaces a driver registered under the same name, and accepts a registration at any time. Suprnova refuses both. The facade builds its driver once per process, so a late registration would do nothing, and an error at boot says so. Two packages that register the same name would otherwise swap each other's hasher in silence, which changes how every password is stored.
Bcrypt with an explicit cost (hash_with_cost)
hash_with_cost(password, cost) and hash_with_cost_async(password, cost) mint a bcrypt hash at a caller-supplied cost factor regardless of HASH_DRIVER. Use these when policy or per-tenant config flows a cost into the call site rather than into the process env - for example, a high-security account class that uses cost 14 while the rest of the app runs at the default 12.
use ;
// Sync - tests, CLI tools.
let h = hash_with_cost?;
// Async - inside Tokio request handlers.
let h = hash_with_cost_async.await?;
Both entry points reject cost outside MIN_BCRYPT_COST..=MAX_BCRYPT_COST (4..=31) with FrameworkError::param, mirroring the env-side HASH_ROUNDS validation:
use ;
assert!; // < 4
assert!; // > 31
The bounds check matters because each cost increment doubles CPU time. At cost 31 a single bcrypt hash takes hours on commodity hardware - bounds-checking inside the framework keeps a policy/config typo from accidentally pinning a worker thread for the rest of the day. The async variant goes through spawn_blocking so even a legitimately high cost doesn't freeze the request loop.
Algorithm-aware needs_rehash
needs_rehash returns true when the stored hash should be re-hashed under the active driver. It covers three cases:
- Algorithm mismatch - bcrypt hash stored while
HASH_DRIVER=argon2id(or vice versa). Triggers a rotation on next successful verify. - Parameter weakness - bcrypt cost below
HASH_ROUNDS, or argonm/t/pbelowHASH_MEMORY/HASH_TIME/HASH_THREADS. - Bcrypt legacy variants -
$2a$,$2x$,$2y$rotate to canonical$2b$even at the configured cost.
if needs_rehash
Malformed input returns true - the caller naturally rotates anything it can't parse.
Hash inspection (info + is_hashed)
use ;
let h = hash_async.await?;
let i = info;
println!;
println!;
println!;
// True for any recognised algorithm hash; false for plaintext / garbage.
assert!;
assert!;
info().algo is one of: Bcrypt, Argon2i, Argon2id, Argon2d (recognised but never minted), Unknown.
is_hashed is what the AsHashed eloquent cast uses to skip re-hashing an already-hashed column - works across all three drivers, so flipping HASH_DRIVER mid-project doesn't cause a hash-of-hash loop on the next save.
Cross-algorithm verification gate (HASH_VERIFY)
By default, verify() checks the password against the hash regardless of which algorithm produced the hash - this is what lets legacy bcrypt hashes still verify after you flip HASH_DRIVER=argon2id (so you can rotate them on login). Set HASH_VERIFY=true once every user is rotated to enforce the active algorithm strictly:
HASH_VERIFY=true
With the gate on, verify() returns Ok(false) for any hash whose algorithm differs from the active driver - same shape as Laravel's RuntimeException, but Suprnova returns false rather than throwing because the auth-flow caller expects a Result<bool> either way.
While LARAVEL_SHARED_DATABASE is on, the gate accepts bcrypt hashes as well as the active driver's: the application then writes bcrypt, and a user whose hash the active driver wrote still signs in, which rewrites the hash as $2y$.
Async vs sync
Both bcrypt at cost 12 (~250 ms) and Argon2id at memory=64 MiB (~80 ms) are intentionally CPU-bound - that's the entire point of slow hashing. Calling the sync hash / verify directly from a Tokio request handler blocks the worker thread for the whole hash duration, starving other requests on the same worker.
Use the *_async siblings inside async fn handlers. They wrap the CPU-bound call in tokio::task::spawn_blocking so the worker stays free for other requests:
// GOOD - inside an async handler
let hashed = hash_async.await?;
// BAD - blocks the worker for ~250 ms
let hashed = hash?;
The sync variants are for tests, CLI tools, and other non-async contexts where blocking is fine.
Eloquent integration: AsHashed cast
The AsHashed eloquent cast hashes a plaintext field on write using the active driver, and is idempotent across all drivers - saving a model whose password column already contains a recognised hash (bcrypt or argon) passes the value through unchanged. Without this guard, User::find(id).await?.save().await? would hash the existing hash on every save, breaking authentication.
use AsHashed;
The idempotence check uses hashing::is_hashed, so flipping HASH_DRIVER mid-project is safe - both the legacy bcrypt hashes and the fresh argon2id hashes are recognised and skipped on re-save.
Use with Auth::attempt
Auth::attempt(&credentials) calls UserProvider::validate_credentials, which in turn calls hashing::verify_async against the user's stored hash. Verify dispatches on the stored hash's algorithm, not the configured driver - so after you flip HASH_DRIVER=argon2id, every existing bcrypt hash still verifies, and needs_rehash returns true so the standard rotate-on-login pattern carries the user base across to the new algorithm one login at a time.
Overriding the driver in tests
set_default_driver(Box<dyn Hasher>) installs a driver programmatically for tests and embedded CLI tools that build the driver without going through HASH_DRIVER. It is one-shot - the first call wins, and a second call returns FrameworkError::internal rather than swapping the driver mid-process. Use it at suite startup, before any code path resolves the default.
Next
- Authentication -
Auth::attempt, the user-provider trait, and how hashing integrates with login - Auth flows -
PasswordReset::completerotates the stored password hash through the active driver; remember-me tokens are hashed before storage viahash_async - Eloquent -
casts = { password = AsHashed }reference and the broader cast surface - Encryption - two-way authenticated encryption for at-rest data; the complement to one-way hashing
- Error Model - what
FrameworkError::paramlooks like when a hashing config value is rejected
