How an HMAC and a rounded clock replaced password_reset_tokens — and the trade-offs nobody mentions.
Every Laravel developer knows the password reset flow. You run the migration, you get a password_reset_tokens table, you call Password::sendResetLink(), and the framework handles the rest. It works. It's battle-tested. It's the right answer most of the time.
I recently built one that stores nothing at all — no table, no cache key, no session. The whole thing is an HMAC and a rounded clock.
This is a walkthrough of how it works, why it was worth doing, and — the part most posts skip — an honest accounting of what it costs you.
The problem with storing reset tokens
Laravel's default is a stateful design. A reset request writes a row:
email | token (hashed) | created_at
alice@example.com | $2y$10$abc... | 2026-07-24 09:00:00
Verification reads that row, compares the token, checks created_at against config('auth.passwords.users.expire'), and deletes it.
Nothing is wrong with this. But it carries a tail of small obligations:
- A migration, against a schema that may already be live.
-
Rows that outlive their usefulness. Expired tokens sit there until something prunes them. Laravel ships
auth:clear-resets— a scheduled command that many teams never actually wire up. - A write on every request. Password reset is a low-volume endpoint, so this rarely matters — but it is a database write in your unauthenticated attack surface, which is a thing an attacker can make you do a lot of.
- Shared state across nodes. Fine with one database. Something to think about with read replicas and replication lag, where a token written to the primary may not be visible to the replica the verification request lands on.
None of these are dealbreakers. They're just there. And I had a codebase where the pattern for avoiding all of them already existed.
The insight: a token that verifies itself
The passwordless-login flow in this application already did something clever, inline, in a controller:
$otp = rand(100000, 999999);
$now = now();
$minuteBlock = floor($now->minute / 5) * 5;
$lastBlockStart = $now->copy()->setTime($now->hour, $minuteBlock, 0)->toDateTimeString();
return response()->json([
'nonce_key' => hash_hmac('sha256', $request->email . $otp . $lastBlockStart, config('app.key')),
]);
The idea underneath: don't store the code — store a proof that you issued it.
The server generates a 6-digit OTP and emails it. It also computes an HMAC over email + otp + time_block, keyed with the application secret, and hands that back to the client as a nonce_key. Then it forgets both.
When the user submits their code, they send back three things: the email, the OTP from their inbox, and the nonce_key their browser has been holding. The server recomputes the HMAC and compares.
If it matches, the code is genuine — because only the server knows APP_KEY, and therefore only the server could have produced that HMAC over that exact triple. The token carries its own proof of authenticity.
The pattern was worth promoting out of the controller and into something reusable:
namespace App\Support;
class OtpNonce
{
public static function generate(string $identifier): array
{
$otp = ! app()->environment('production')
? '123456'
: (string) random_int(100000, 999999);
return [
'otp' => $otp,
'nonce_key' => self::hash($identifier, $otp, self::currentBlock()),
];
}
public static function verify(string $identifier, string $otp, string $nonceKey): bool
{
foreach ([self::currentBlock(), self::currentBlock(-5)] as $block) {
if (hash_equals(self::hash($identifier, $otp, $block), $nonceKey)) {
return true;
}
}
return false;
}
private static function hash(string $identifier, string $otp, string $block): string
{
return hash_hmac('sha256', $identifier.$otp.$block, config('app.key'));
}
private static function currentBlock(int $offsetMinutes = 0): string
{
$now = now()->addMinutes($offsetMinutes);
$minuteBlock = floor($now->minute / 5) * 5;
return $now->copy()->setTime($now->hour, (int) $minuteBlock, 0)->toDateTimeString();
}
}
Forty lines. That's the whole mechanism.
Three details that carry the weight
Expiry that requires no cleanup
currentBlock() floors the clock to a 5-minute boundary. 09:07:41 becomes 09:05:00.
That timestamp goes inside the hash. Which means: once the wall clock moves past the accepted window, the same email + otp pair hashes to a different value, and the old nonce_key simply stops matching.
The token expires because time moved, not because anything deleted it. There is no cron job. There is no TTL to configure. There is nothing to garbage-collect.
But quantised time has a sharp edge. A code issued at 09:04:59 is bound to block 09:00 — and one second later, currentBlock() returns 09:05. One second of life.
The fix is to accept the previous window too:
foreach ([self::currentBlock(), self::currentBlock(-5)] as $block) {
Now every code lives at least 5 minutes and at most 10, depending on where in the window it was issued. It's the same trade-off TOTP authenticator apps make when they accept the adjacent time step to tolerate clock skew: a little precision traded for a lot of usability.
hash_equals, always
if (hash_equals(self::hash($identifier, $otp, $block), $nonceKey)) {
PHP's === short-circuits at the first differing byte. Comparing a secret with it leaks, through response latency, how many leading bytes you got right. An attacker who can measure that reconstructs the expected value byte by byte — turning 2²⁵⁶ guesses into about 8,000.
hash_equals() compares in constant time. It costs nothing. Use it every single time you compare something secret.
Binding is what makes it a token
hash_hmac('sha256', $identifier.$otp.$block, config('app.key'));
Every input inside the hash is a property the token now enforces:
-
identifierinside → a nonce minted foralice@will never validate forbob@. -
otpinside → you can't pair a stolen nonce with a guessed code. -
blockinside → it expires. -
APP_KEYas the key → nobody outside the server can forge one.
This last point deserves emphasis, because it's the one people get wrong. If you use a plain hash('sha256', ...) over public inputs, anyone can compute it and the token proves precisely nothing. HMAC keys the hash with a server secret. That's the entire difference between a token and a checksum.
A useful side effect: rotating APP_KEY instantly invalidates every outstanding code. Handy during an incident. Slightly annoying during a routine rotation.
Splitting knowledge across two channels
The controller side is where the design's real security property shows up:
public function forgotPassword(Request $request)
{
$request->validate(['email' => ['required', 'email:rfc,dns']]);
$user = User::where('email', $request->input('email'))->first();
$otp = OtpNonce::generate($request->input('email'));
if ($user) {
$user->notify(new UserForgotPasswordOtp($otp['otp']));
}
return response()->json([
'status' => true,
'message' => 'If that email is registered, a reset code has been sent.',
'nonce_key' => $otp['nonce_key'],
]);
}
Two things are deliberate here.
First: the OTP is generated unconditionally. Look at the ordering — generate() runs whether or not the account exists. Only the email send is conditional. So a registered address and an unregistered one produce byte-identical responses: same status, same message, same shape, a real nonce_key either way.
That closes user enumeration. An attacker can't use this endpoint to work out which addresses have accounts. The deliberately hedged copy — "If that email is registered…" — exists for the same reason, and is why GitHub, Stripe, and Laravel Fortify all word theirs the same way.
Second: the two secrets travel over different channels. The OTP goes to the inbox. The nonce_key goes to the browser. Verification needs both.
An attacker who compromises the email but not the session has a code and no nonce. One who intercepts the HTTP response but not the inbox has a nonce and no code. Neither half is sufficient.
Verification is then trivial:
abort_unless(
OtpNonce::verify($request->email, $request->otp, $request->nonce_key),
Response::HTTP_UNPROCESSABLE_ENTITY,
'Invalid or expired code'
);
$user = User::where('email', $request->email)->first();
abort_unless($user, Response::HTTP_UNPROCESSABLE_ENTITY, 'Invalid or expired code');
The HMAC check runs first — it's pure CPU, while the lookup is a query, so forged requests never cost a round-trip. And both failures return the identical message. "Wrong code" and "no such user" are indistinguishable from outside. The repetition is the point.
Now the part that matters: what this costs you
Here's where most posts about a clever pattern stop. They shouldn't.
Statelessness means you cannot enforce single use
This is the big one, and it follows directly from the design rather than from any mistake in it.
With nothing stored, nothing can be marked as used. A code that successfully resets a password stays valid for the remainder of its window and can reset it again. A stateful token gets deleted on use. This one can't be.
Any fix reintroduces exactly the state you were avoiding:
$fingerprint = 'otp_used:'.hash('sha256', $nonceKey);
abort_if(Cache::has($fingerprint), 422, 'Invalid or expired code');
Cache::put($fingerprint, true, now()->addMinutes(10));
That's a legitimate middle ground — a 10-minute cache key is a far lighter obligation than a permanent table. But be clear-eyed: you are now stateful. Make it a decision, not an accident.
Statelessness also means you can't count attempts
And this is where it gets genuinely dangerous.
The routes as first written carried no throttle middleware — while the email-verification routes directly beside them in the same file used throttle:6,1.
The attack is short:
-
POST /forgot-passwordwith the victim's email. You get a validnonce_keyin the response. - Brute-force
POST /reset-passwordacross the 900,000-code space. - You never needed the victim's inbox at all.
Nothing burns attempts. Nothing locks out. Nothing alerts. The split-knowledge property I described above is exactly what collapses, because the endpoint hands you one of the two halves for free.
A stateful implementation gets a natural attempt counter — the row is right there. A stateless one has nowhere to put it, so the throttle has to be external:
Route::middleware(['guest', 'throttle:5,10'])->post('reset-password', ...);
Route::middleware(['guest', 'throttle:3,10'])->post('forgot-password', ...);
Throttling on email and IP is stronger. But the honest framing is this: statelessness didn't remove the need for state — it moved it into the rate limiter. If you skip that step, you have built a very elegant account-takeover endpoint.
Environment gates are sharper than they look
$otp = ! app()->environment('production') ? '123456' : random_int(100000, 999999);
A fixed dev code is genuinely good DX — you test the flow in Postman without opening a mail catcher every time.
But read the condition carefully. It says "not production." Which means staging, testing, demo, and any host with a typo'd APP_ENV all issue 123456. Combined with the nonce_key coming back in the response body, anyone can reset any account on such a host with zero email access.
Laravel defaults APP_ENV to production when unset, which is the right direction to fail. But deny-lists fail open by nature. Allow-list instead:
$fixed = app()->environment('local', 'testing') && config('auth.otp.fixed_code');
Your existing tokens don't care that the password changed
This one isn't specific to stateless OTPs, but it's specific to JWT auth, and the two ship together often enough to be worth stating.
With jwt-auth, tokens are self-contained and valid until they expire. Changing the password does not invalidate them. An attacker holding a token keeps their access after the victim resets — which defeats the single most common reason a user resets a password under duress.
You need a token_version claim you can bump, or a blacklist entry on reset. Not optional if the reset flow is meant to be a recovery mechanism.
So: was it worth it?
For this codebase, yes — with the throttle added.
What you get: no migration on a live schema, no cleanup job, no shared state between nodes, and a verification path that's pure computation. Any app server can verify any code with no coordination whatsoever. In a horizontally-scaled deployment, that's a genuinely nice property, and it comes from forty lines with no infrastructure attached.

Top comments (0)