Solution and explanation
Goal: POST /api/login returns a token for a mobile or SPA client, resists brute force and does not leak which emails exist.
Step 1, validation and rate limiting:
// RouteServiceProvider / bootstrap: 5 attempts per minute per email+IP
RateLimiter::for('login', fn (Request $r) => Limit::perMinute(5)->by(Str::lower($r->input('email')) . '|' . $r->ip()));
Route::post('/login', LoginController::class)->middleware('throttle:login');
$data = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string'], 'device_name' => ['required', 'string', 'max:100']]);
Step 2, verify the password with the hash (bcrypt/argon2), and give the same error for a wrong email or a wrong password:
$user = User::where('email', Str::lower($data['email']))->first();
if (! $user || ! Hash::check($data['password'], $user->password)) {
throw ValidationException::withMessages(['email' => 'These credentials do not match our records.']);
}
if (! $user->is_active) abort(403, 'This account is disabled.');
Step 3, issue a token with limited abilities and an expiry (Sanctum):
$token = $user->createToken($data['device_name'], ['orders:read', 'orders:write'], now()->addDays(30))->plainTextToken;
return response()->json(['success' => true, 'token' => $token, 'token_type' => 'Bearer',
'user' => new UserResource($user)]);
Sanctum stores only a hash of the token, so a database leak does not expose usable tokens.
Step 4, protect routes and check abilities:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/profile', ProfileController::class);
Route::post('/orders', StoreOrderController::class)->middleware('ability:orders:write');
Route::post('/logout', fn (Request $r) => tap(response()->noContent(), fn () => $r->user()->currentAccessToken()->delete()));
});
Step 5, production hardening:
- HTTPS only; never log passwords or tokens (redact them in logging).
- Rehash passwords when the hashing cost increases (Hash::needsRehash).
- Two-factor or OTP for sensitive apps; for banking, require step-up OTP again for transfers even with a valid token.
- Lock or slow down accounts after repeated failures and notify the user of new device logins.
- Revoke all tokens on password change: $user->tokens()->delete().
- Store tokens on mobile in secure storage (Keychain/Keystore), not plain preferences.
Flow:
Client -> POST /api/login (throttled) -> validate -> Hash::check -> createToken(abilities, expiry)
Client -> Authorization: Bearer <token> -> auth:sanctum -> ability check -> policy -> controller
Interview answer: "Validate and throttle, verify hashed passwords with a generic error, issue scoped expiring Sanctum tokens, protect routes with auth:sanctum and abilities, revoke tokens on logout and password change, and add OTP for high-risk actions."