This is a rewrite of our original 2018 post.
Laravel has changed dramatically since then. Fortify replaced auth scaffolding, Inertia replaced Blade, and even the AWS auth flow got renamed. The approach is simpler now: no custom guard, no trait swapping, just a thin service class and a few Fortify hooks.
Why Cognito?
If you have multiple apps and want users to log in with the same credentials across all of them, you need a shared identity provider, which is what a Single Sign-On (SSO) solution gives you. AWS Cognito is exactly that: a fully managed, serverless authentication service that handles registration, email verification, password policies, and password resets through a centralized user pool. Your Laravel app keeps a local users table for relationships and authorization, but Cognito owns the passwords.
Cognito's free tier covers 10,000 monthly active users on the Lite and Essentials tiers (accounts created before November 2024 still get 50,000). For most projects, that means authentication is free.
What We're Building
By the end of this post you'll have a Laravel 13 app with these flows, all backed by Cognito:
- Registration: user signs up, Cognito sends a 6-digit confirmation code via email.
- Email confirmation: user enters the code to activate their account, with a "resend code" fallback.
- Login: credentials validated against Cognito, local user record created automatically on first login.
- Password reset: "forgot password" sends a Cognito reset code, user enters it with their new password.
- Password change: authenticated user changes their password from the settings page.
- Account deletion: removes the user from both Cognito and the local database.
The frontend uses Inertia with React, but the backend integration works the same regardless of your frontend choice. The full example app is on GitHub, and we'll link to it at the end.
Prerequisites
- PHP 8.4 and Laravel 13 with Fortify, which is Laravel's headless authentication backend. The official
laravel/react-starter-kitships with it. - An AWS account
- A Cognito user pool (we'll set one up next)
Setting Up the Cognito User Pool
You have two options: the AWS Console, or Terraform if you prefer infrastructure-as-code. The Terraform route is faster and reproducible. The full module is available in the example app's repository under _terraform/.
Option A: Terraform (recommended)
The Terraform module creates three things: a user pool, an app client, and a least-privilege IAM user for your Laravel app.
The user pool uses email as the username, auto-verifies email addresses, and sends confirmation codes:
resource "aws_cognito_user_pool" "this" {
name = "my-app"
username_attributes = ["email"]
auto_verified_attributes = ["email"]
password_policy {
minimum_length = 8
require_lowercase = true
require_numbers = true
require_symbols = true
require_uppercase = true
}
schema {
name = "email"
attribute_data_type = "String"
mutable = true
required = true
}
schema {
name = "name"
attribute_data_type = "String"
mutable = true
required = true
}
verification_message_template {
default_email_option = "CONFIRM_WITH_CODE"
}
}
The app client enables the server-side auth flow and generates a client secret:
resource "aws_cognito_user_pool_client" "this" {
name = "my-app-client"
user_pool_id = aws_cognito_user_pool.this.id
generate_secret = true
explicit_auth_flows = [
"ALLOW_ADMIN_USER_PASSWORD_AUTH",
"ALLOW_REFRESH_TOKEN_AUTH",
]
prevent_user_existence_errors = "ENABLED"
}
Three things here are critical:
ALLOW_ADMIN_USER_PASSWORD_AUTHis the auth flow that lets your server send passwords directly to Cognito. Without it, authentication will fail. The old name wasADMIN_NO_SRP_AUTH, so if you see that in older tutorials, it's the same thing.generate_secret = truegives the app client a secret, and every API call must include an HMAC hash computed from it. More on this later.prevent_user_existence_errors = "ENABLED"stops Cognito from revealing whether an email is registered. Without it, failed logins would return different error codes for "wrong password" vs. "user doesn't exist", which enables user enumeration attacks.
The IAM user is scoped to only the Cognito actions the app actually calls:
data "aws_iam_policy_document" "app" {
statement {
effect = "Allow"
actions = [
"cognito-idp:AdminDeleteUser",
"cognito-idp:AdminInitiateAuth",
"cognito-idp:AdminSetUserPassword",
"cognito-idp:SignUp",
"cognito-idp:ConfirmSignUp",
"cognito-idp:ResendConfirmationCode",
"cognito-idp:ForgotPassword",
"cognito-idp:ConfirmForgotPassword",
]
resources = [aws_cognito_user_pool.this.arn]
}
}
Eight actions, pinned to one pool. This is least-privilege: the IAM user can't read the pool config, list users, or touch any other pool.
To apply:
cd _terraform
terraform init
terraform plan -var="profile=your-aws-profile"
terraform apply -var="profile=your-aws-profile"
Then wire the outputs into your .env:
terraform output -raw aws_cognito_region # → AWS_COGNITO_REGION
terraform output -raw aws_cognito_user_pool_id # → AWS_COGNITO_USER_POOL_ID
terraform output -raw aws_cognito_client_id # → AWS_COGNITO_CLIENT_ID
terraform output -raw aws_cognito_client_secret # → AWS_COGNITO_CLIENT_SECRET
terraform output -raw aws_access_key_id # → AWS_ACCESS_KEY_ID
terraform output -raw aws_secret_access_key # → AWS_SECRET_ACCESS_KEY
The full Terraform module, including the README with the deployer IAM policy and cleanup instructions, is in the example repository under _terraform/.
Option B: AWS Console
Note: AWS regularly updates the Cognito console UI, so the exact screens and labels may differ from what's described here. The underlying settings tend to change far less frequently than the interface. If a step doesn't match what you see, look for the same concept under a slightly different name or menu location.
If you'd rather click through the console:
-
Open the Cognito console and click Create user pool
-
Under Sign-in options, select Email as a username attribute
-
Configure your password policy (Cognito defaults to 8+ characters, mixed case, numbers, and symbols)
-
Under Required attributes, add name and email
-
Under Email, keep Send email with Cognito (it sends a confirmation code, no SES setup needed for development)
-
Under App clients, create a new client with:
- Generate a client secret: enabled
- Authentication flows: check
ALLOW_ADMIN_USER_PASSWORD_AUTHandALLOW_REFRESH_TOKEN_AUTH

If you can't find the checkboxes, the Authentication flows section is about a third of the way down the App client page.
ALLOW_ADMIN_USER_PASSWORD_AUTHis the non-obvious one: older tutorials and StackOverflow answers call itADMIN_NO_SRP_AUTH. AWS renamed it but the behavior is identical.
-
Create an IAM user with an access key, and attach a policy that allows the eight
cognito-idpactions listed above, scoped to your pool's ARN
Note the User pool ID (format: us-east-1_XXXXXXXXX), Client ID, and Client secret. You'll need all three.
Laravel Setup
Install the AWS SDK for PHP
composer require aws/aws-sdk-php
The aws/aws-sdk-php package gives us CognitoIdentityProviderClient, the SDK client for all Cognito API calls.
Configuration
Create config/cognito.php:
<?php
return [
'region' => env('AWS_COGNITO_REGION', env('AWS_DEFAULT_REGION', 'us-east-1')),
'user_pool_id' => env('AWS_COGNITO_USER_POOL_ID'),
'client_id' => env('AWS_COGNITO_CLIENT_ID'),
'client_secret' => env('AWS_COGNITO_CLIENT_SECRET'),
'version' => env('AWS_COGNITO_VERSION', 'latest'),
];
Add to your .env:
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_COGNITO_REGION=us-east-1
AWS_COGNITO_USER_POOL_ID=us-east-1_XXXXXXXXX
AWS_COGNITO_CLIENT_ID=your-client-id
AWS_COGNITO_CLIENT_SECRET=your-client-secret
The SDK picks up AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY automatically from the environment, so there's no need to pass them in code. If you're hosting on AWS, you can skip these variables entirely and use the platform's built-in credential mechanisms instead: EC2 instance profiles, ECS task roles, or IAM Roles for Service Accounts (IRSA) on EKS. The SDK's default credential provider chain tries these automatically, so your app code stays the same. You just attach the right IAM role to your compute resource and remove the access key variables from .env.
Make passwords nullable
Since Cognito owns passwords, your local users table doesn't need one. In your migration:
$table->string('password')->nullable();
The column stays in the schema to avoid breaking anything that references it, but we never write a hash to it.
Mirror the Cognito password policy locally
The user pool has a password policy, and ours requires minimum 8 characters with mixed case, numbers, and symbols. If your local validation rules are weaker, users will fill in a "valid" password, submit, and Cognito will reject it with a generic "Password did not conform with policy". That's ugly UX. If your local rules are stricter, you're lying to Cognito about what passwords it accepts.
The simplest fix is to teach Laravel's Password::defaults() the same policy once, then reuse it everywhere. In AppServiceProvider::boot():
use Illuminate\Validation\Rules\Password;
protected function configureDefaults(): void
{
// Mirrors the Cognito user pool password policy defined in
// _terraform/cognito.tf. Keep the two in sync.
Password::defaults(fn () => Password::min(8)
->mixedCase()
->numbers()
->symbols());
}
Now Password::default() (singular) is the policy object you reach for in any validation rule. You'll see it used in CreateNewUser, ResetPasswordController, and SecurityController below.

The Cognito Client
This is the only class that talks to AWS. ~120 lines, one method per Cognito operation:
<?php
declare(strict_types=1);
namespace App\Services;
use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;
use Aws\CognitoIdentityProvider\Exception\CognitoIdentityProviderException;
class CognitoClient
{
public function __construct(
private readonly CognitoIdentityProviderClient $client,
private readonly string $clientId,
private readonly string $clientSecret,
private readonly string $userPoolId,
) {}
public function authenticate(string $email, string $password): bool
{
try {
$result = $this->client->adminInitiateAuth([
'AuthFlow' => 'ADMIN_USER_PASSWORD_AUTH',
'ClientId' => $this->clientId,
'UserPoolId' => $this->userPoolId,
'AuthParameters' => [
'USERNAME' => $email,
'PASSWORD' => $password,
'SECRET_HASH' => $this->secretHash($email),
],
]);
} catch (CognitoIdentityProviderException $e) {
if (in_array($e->getAwsErrorCode(), ['NotAuthorizedException', 'UserNotFoundException'])) {
return false;
}
throw $e;
}
return isset($result['AuthenticationResult']);
}
public function register(string $email, string $password, string $name): void
{
$this->client->signUp([
'ClientId' => $this->clientId,
'SecretHash' => $this->secretHash($email),
'Username' => $email,
'Password' => $password,
'UserAttributes' => [
['Name' => 'email', 'Value' => $email],
['Name' => 'name', 'Value' => $name],
],
]);
}
public function confirmSignUp(string $email, string $code): void
{
$this->client->confirmSignUp([
'ClientId' => $this->clientId,
'SecretHash' => $this->secretHash($email),
'Username' => $email,
'ConfirmationCode' => $code,
]);
}
public function resendConfirmationCode(string $email): void
{
$this->client->resendConfirmationCode([
'ClientId' => $this->clientId,
'SecretHash' => $this->secretHash($email),
'Username' => $email,
]);
}
public function forgotPassword(string $email): void
{
$this->client->forgotPassword([
'ClientId' => $this->clientId,
'SecretHash' => $this->secretHash($email),
'Username' => $email,
]);
}
public function confirmForgotPassword(string $email, string $code, string $password): void
{
$this->client->confirmForgotPassword([
'ClientId' => $this->clientId,
'SecretHash' => $this->secretHash($email),
'Username' => $email,
'ConfirmationCode' => $code,
'Password' => $password,
]);
}
public function adminSetUserPassword(string $email, string $password): void
{
$this->client->adminSetUserPassword([
'UserPoolId' => $this->userPoolId,
'Username' => $email,
'Password' => $password,
'Permanent' => true,
]);
}
public function adminDeleteUser(string $email): void
{
$this->client->adminDeleteUser([
'UserPoolId' => $this->userPoolId,
'Username' => $email,
]);
}
private function secretHash(string $username): string
{
return base64_encode(
hash_hmac('sha256', $username . $this->clientId, $this->clientSecret, true)
);
}
}
The secret hash
Every method calls secretHash(). This is easy to miss, and the error message when you forget it ("Unable to verify secret hash") is not helpful. If your app client has a client secret (and it should), every Cognito API call must include an HMAC-SHA256 hash of the username concatenated with the client ID, keyed by the client secret. That's what this one-liner does.
Error handling
authenticate() catches NotAuthorizedException (wrong password) and UserNotFoundException (email doesn't exist) and returns false for both, so the login form shows a generic error without leaking which emails are registered. All other exceptions (network errors, misconfiguration) bubble up.
Note that UserNotConfirmedException is not caught here. That one gets special treatment in the Fortify wiring; see the login section below.
Registering the singleton in the service provider
In AppServiceProvider::register():
use App\Services\CognitoClient;
use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;
$this->app->singleton(CognitoClient::class, function ($app): CognitoClient {
$config = $app['config']['cognito'];
return new CognitoClient(
new CognitoIdentityProviderClient([
'region' => $config['region'],
'version' => $config['version'],
]),
$config['client_id'],
$config['client_secret'],
$config['user_pool_id'],
);
});
We don't pass AWS credentials to the SDK constructor; it resolves them from the default credential provider chain (environment variables, instance profiles, IRSA, etc.).
Why authenticateUsing Instead of a Custom Guard?
In our original 2018 post, we built a custom CognitoGuard that extended Laravel's SessionGuard. That was the right approach at the time, because it was the only way to intercept credential validation. With Fortify, there's a simpler option: Fortify::authenticateUsing().
This hook lets you replace how credentials are validated while keeping the stock session guard for everything else: session management, remember-me, logout, CSRF. No subclass, no service provider registration, no Auth::extend().
When does a custom guard still make sense?
- Storing Cognito tokens. If you need the access token, refresh token, or ID token (JWT) from Cognito for calling other AWS services or for token refresh without re-login, a guard can store them in the session as a side effect of login.
- Multi-step challenge flows. MFA and forced password change require Cognito to return a "challenge" instead of tokens on the first login call, then a second call with the challenge response. A guard can model this as internal state. With
authenticateUsing, you'd have to throw exceptions to interrupt the flow, which is awkward. - Dual auth strategies. If you need both session-based web auth and stateless API token auth against the same pool, two separate guards (session + token) are the clean solution.
For straightforward session-based auth, which is what most Laravel apps need, authenticateUsing is the recommended approach and what we use here. If you later add MFA, that's the natural point to consider a custom guard.
Wiring Up Fortify
Login
In FortifyServiceProvider::boot():
use App\Models\User;
use App\Services\CognitoClient;
use Aws\CognitoIdentityProvider\Exception\CognitoIdentityProviderException;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
Fortify::authenticateUsing(function (Request $request) {
$email = (string) $request->input(Fortify::username());
$password = (string) $request->input('password');
try {
if (! app(CognitoClient::class)->authenticate($email, $password)) {
return null;
}
} catch (CognitoIdentityProviderException $e) {
if ($e->getAwsErrorCode() === 'UserNotConfirmedException') {
throw new HttpResponseException(
redirect()->route('confirm-signup.show', ['email' => $email])
->with('status', 'Please confirm your account before logging in.')
);
}
throw $e;
}
return User::firstOrCreate(
['email' => $email],
['name' => Str::before($email, '@')],
);
});
Fortify calls this closure instead of checking a password hash, and the stock session guard handles everything else. If Cognito accepts the credentials, we return the local user (creating one on the fly via firstOrCreate if this is their first login). If Cognito rejects them, we return null and Fortify shows the standard "These credentials do not match our records" error.
The firstOrCreate pattern also enables gradual user migration. If you're adding Cognito to an existing app, users who already exist in the Cognito pool but not in the local database will get a local record created automatically on their first login.
The UserNotConfirmedException catch is important. When someone registers but skips the confirmation step, then later tries to log in, Cognito throws this instead of a normal auth failure. Without the catch, your app would 500. We redirect them to the confirmation page so they can finish signing up.
While we're at it, override the default auth.failed translation so the generic failure message covers both the wrong-password case and the unconfirmed-account case. Create lang/en/auth.php:
<?php
return [
'failed' => 'These credentials are invalid, or your account has not yet been confirmed.',
'password' => 'The provided password is incorrect.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
];
That way, if a user forgot to click the confirmation link and tries to log in with the wrong password, the response still hints that they might need to confirm, without revealing whether that specific email is registered.
Registration
The CreateNewUser action registers the user in Cognito first, then creates a local record without a password:
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use App\Services\CognitoClient;
use Aws\CognitoIdentityProvider\Exception\CognitoIdentityProviderException;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
public function __construct(private readonly CognitoClient $cognito) {}
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'confirmed', Password::default()],
])->validate();
try {
$this->cognito->register($input['email'], $input['password'], $input['name']);
} catch (CognitoIdentityProviderException $e) {
throw ValidationException::withMessages([
'email' => $e->getAwsErrorMessage() ?: $e->getMessage(),
]);
}
return User::create([
'name' => $input['name'],
'email' => $input['email'],
]);
}
}
If Cognito rejects the registration (duplicate email, weak password, etc.), the exception is caught and surfaced as a validation error on the email field.
Redirect after registration
After registration, Cognito sends a confirmation code to the user's email. The user can't log in yet; they need to confirm first. Override the RegisterResponse in AppServiceProvider::register() to redirect them:
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\RegisterResponse;
use Symfony\Component\HttpFoundation\Response;
// Fortify's RegisteredUserController logs the user in right after
// CreateNewUser returns, which bypasses Cognito's confirmation gate
// (UserNotConfirmedException is only raised on AdminInitiateAuth, and
// we never call that during registration). Tear the session down
// before redirecting so the user must come back through login, which
// is the path that actually enforces the confirmation check.
$this->app->singleton(RegisterResponse::class, fn () => new class implements RegisterResponse
{
public function toResponse($request): Response
{
Auth::guard()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('confirm-signup.show', [
'email' => $request->input('email'),
])->with('status', 'We sent a confirmation code to your email.');
}
});
The logout call is the non-obvious bit. If you skip it, the form submission succeeds, Fortify quietly logs the unconfirmed user in, and your redirect lands them on the confirmation page. If they then ignore it and type /dashboard into the address bar, they're through. Tearing the session down forces them back through /login, which is the code path where Cognito's UserNotConfirmedException actually fires.
The ->with('status', ...) is just UX polish: it flashes a green confirmation banner on the confirm-signup page so the user gets immediate feedback that the email has been sent.
Fortify features
Since Cognito owns email verification and password resets, disable the Fortify features that would conflict. In config/fortify.php:
'features' => [
Features::registration(),
],
We keep registration() because Fortify's register route and our CreateNewUser action handle the form submission. But resetPasswords(), emailVerification(), and twoFactorAuthentication() are all replaced, either by our custom controllers or by Cognito itself.
Rate-limit the Fortify-owned routes
Fortify's built-in login limiter (per-email+IP, 5/min) covers POST /login, but POST /register is defenseless by default, and Cognito bills you for every sign-up email. Add a coarse per-IP limiter to the Fortify middleware stack and register the named limiter alongside the existing login one.
In config/fortify.php:
'middleware' => ['web', 'throttle:fortify'],
In FortifyServiceProvider::boot(), next to the login limiter:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
// Coarse per-IP ceiling for all Fortify-owned endpoints (register,
// logout, etc.). The stricter per-email+IP login limiter above stacks
// on top, so login is still bounded at 5/min per user.
RateLimiter::for('fortify', fn (Request $request) => Limit::perMinute(30)->by($request->ip()));
30/min is generous enough that a human never notices but caps automated abuse of the register endpoint, exactly the endpoint Cognito charges for.
Email Confirmation (Verification)
When Cognito creates a user via signUp(), the account starts in an UNCONFIRMED state. Cognito sends an email verification code (a 6-digit number) and the user must enter it to activate their account.
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\CognitoClient;
use Aws\CognitoIdentityProvider\Exception\CognitoIdentityProviderException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmSignUpController extends Controller
{
public function show(Request $request): Response
{
return Inertia::render('auth/confirm-signup', [
'email' => (string) $request->query('email', ''),
'status' => $request->session()->get('status'),
]);
}
public function store(Request $request, CognitoClient $cognito): RedirectResponse
{
$data = $request->validate([
'email' => ['required', 'string', 'email'],
'code' => ['required', 'string'],
]);
try {
$cognito->confirmSignUp($data['email'], $data['code']);
} catch (CognitoIdentityProviderException $e) {
throw ValidationException::withMessages([
'code' => $e->getAwsErrorMessage() ?: $e->getMessage(),
]);
}
// Auto-login: retrieving the code from the inbox already proves
// email ownership, which is the same factor that gates password
// reset. Requiring a second password entry is friction without a
// meaningful security gain. Regenerate the session to avoid fixation.
$user = User::where('email', $data['email'])->first();
if ($user) {
Auth::login($user);
$request->session()->regenerate();
return redirect()->route('dashboard');
}
return redirect()->route('login')->with('status', 'Account confirmed. Please log in.');
}
public function resend(Request $request, CognitoClient $cognito): RedirectResponse
{
$data = $request->validate([
'email' => ['required', 'string', 'email'],
]);
try {
$cognito->resendConfirmationCode($data['email']);
} catch (CognitoIdentityProviderException $e) {
if ($e->getAwsErrorCode() !== 'UserNotFoundException') {
throw ValidationException::withMessages([
'email' => $e->getAwsErrorMessage() ?: $e->getMessage(),
]);
}
}
return back()->with('status', 'A new confirmation code has been sent to your email.');
}
}

The resend method matters. Cognito's default email sender (no-reply@verificationemail.com) has poor deliverability and emails frequently land in spam, so users will need a way to request a new code. The green banner above is the flash status from either the register redirect or a fresh "Resend code" click, which is the same component with two entry points.
Worth pausing on the auto-login call inside store. You could instead send the user to /login and make them type their password again. That's what Laravel's email-verification flow does by default. We don't, because retrieving the code from the inbox is itself proof of email ownership, which is the same factor that gates password reset. Requiring a password re-entry here is extra friction that doesn't buy any security: if the confirmation code were intercepted, the interceptor already has mailbox access and could do a password reset. The one edge case worth naming is social-engineering a victim into submitting a confirm form with an attacker-owned email plus code. In practice that means tricking the victim into typing someone else's email, which is implausible for a human user and blocked by a reasonable account-takeover threat model.
Password Reset
Cognito handles password resets with its own confirmation code flow, so we bypass Laravel's password broker entirely. Two controllers replace Fortify's built-in forgot/reset routes.
ForgotPasswordController sends the reset code:
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Services\CognitoClient;
use Aws\CognitoIdentityProvider\Exception\CognitoIdentityProviderException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ForgotPasswordController extends Controller
{
public function show(Request $request): Response
{
return Inertia::render('auth/forgot-password', [
'status' => $request->session()->get('status'),
]);
}
public function store(Request $request, CognitoClient $cognito): RedirectResponse
{
$data = $request->validate([
'email' => ['required', 'string', 'email'],
]);
try {
$cognito->forgotPassword($data['email']);
} catch (CognitoIdentityProviderException $e) {
if ($e->getAwsErrorCode() !== 'UserNotFoundException') {
throw $e;
}
}
return redirect()->route('password.reset', ['email' => $data['email']])
->with('status', 'We have emailed your password reset code.');
}
}
We swallow UserNotFoundException so the response doesn't reveal whether an email is registered.
ResetPasswordController validates the code and sets the new password:
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\CognitoClient;
use Aws\CognitoIdentityProvider\Exception\CognitoIdentityProviderException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ResetPasswordController extends Controller
{
public function show(Request $request): Response
{
return Inertia::render('auth/reset-password', [
'email' => (string) $request->query('email', ''),
'status' => $request->session()->get('status'),
]);
}
public function store(Request $request, CognitoClient $cognito): RedirectResponse
{
$data = $request->validate([
'email' => ['required', 'string', 'email'],
'code' => ['required', 'string'],
'password' => ['required', 'string', 'confirmed', Password::default()],
]);
try {
$cognito->confirmForgotPassword($data['email'], $data['code'], $data['password']);
} catch (CognitoIdentityProviderException $e) {
throw ValidationException::withMessages([
'code' => $e->getAwsErrorMessage() ?: $e->getMessage(),
]);
}
// Rotate the local `password` column hash so any still-live session
// for this user (another browser, a forgotten device) fails
// AuthenticateSession's snapshot check and gets killed on its next
// request. The user isn't logged in here, so we can't use
// Auth::logoutOtherDevices; we update the column directly.
User::where('email', $data['email'])
->update(['password' => bcrypt(Str::random(60))]);
return redirect()->route('login')->with('status', 'Password reset. Please log in.');
}
}
Note the reset page takes a confirmation code, not a token URL like Laravel's built-in password reset. The user enters their email, the 6-digit code from the email, and their new password on a single form. The AuthenticateSession hash-rotation trick is the same one we use in the password-change flow below; see that section for the rationale.
Password Change
The starter kit ships with a "change password" form in the security settings. Since Cognito owns passwords, this can't use Laravel's built-in current_password validation rule, because that checks against the local database hash, which is null.
Cognito offers two APIs for changing passwords:
ChangePasswordtakes the old password, new password, and the user's access token in a single call. Designed for client-side flows where the user holds their own token.AdminSetUserPasswordis an admin-level call that sets the password directly, authorized by IAM credentials. AWS recommends this for server-side applications.
We use AdminSetUserPassword because our app uses IAM credentials throughout and doesn't store Cognito access tokens. The tradeoff is two API calls instead of one: we call authenticate() first to verify the current password, then adminSetUserPassword() to set the new one. One extra concern once the new password is in: any other active session (another browser, a forgotten device, a stolen cookie) still holds valid auth. A common reason a user changes their password is precisely that they suspect one of those sessions, so we also kill them all after the change succeeds.
Laravel has a built-in mechanism for this: the AuthenticateSession middleware. On every authenticated request it compares a snapshot of the user's password column hash (stashed in the session) against the current value in the database. If they differ, the session is invalidated. Rotate the hash and every sibling session dies on its next request, no matter what session driver you're using.
Two pieces to wire this up. First, add the middleware to the web group in bootstrap/app.php:
use Illuminate\Session\Middleware\AuthenticateSession;
$middleware->web(append: [
AuthenticateSession::class,
// ... other middleware
]);
Second, the controller:
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
public function update(Request $request, CognitoClient $cognito): RedirectResponse
{
$data = $request->validate([
'current_password' => ['required', 'string'],
'password' => ['required', 'string', 'confirmed', Password::default()],
]);
$email = $request->user()->email;
if (! $cognito->authenticate($email, $data['current_password'])) {
throw ValidationException::withMessages([
'current_password' => __('The password is incorrect.'),
]);
}
try {
$cognito->adminSetUserPassword($email, $data['password']);
} catch (CognitoIdentityProviderException $e) {
throw ValidationException::withMessages([
'password' => $e->getAwsErrorMessage() ?: $e->getMessage(),
]);
}
// Rotate the local `password` column. AuthenticateSession compares
// each session's stashed snapshot to the current column hash on every
// request, so any sibling session mismatches and is killed on its
// next request. The value is a random string (not the real new
// password) so the column stays a session-version marker, not a
// local password store.
$request->user()->update(['password' => Str::random(60)]);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]);
return back();
}
This replaces the stock password update logic entirely, and no real password hash is stored locally. The Password::default() rule reuses the policy we configured earlier so the local validation matches Cognito's.
Two things to note about the column-rotation line:
- Why a random string instead of the real password? The
users.passwordcolumn (with the defaulthashedcast) storesbcrypt($value)for whatever you assign. We don't want a crackable hash of the user's real Cognito password sitting in our database. It wouldn't buy an attacker anything authentication-wise (the local column is never consulted;authenticateUsingalways delegates to Cognito), but it's one less piece of correlated password material in case the DB leaks. A random string's hash is cryptographically equivalent for the session-version-check use case. - Why not
Auth::logoutOtherDevices()? It's the natural reach, but it runsHash::check($arg, $user->password)as a guard before rotating. Our column holds a random session-version marker (Cognito owns the real password), so no argument ever matches and the call throwsInvalidArgumentException: The given password does not match the current password.Writing the column ourselves sidesteps the guard. The current session still stays signed in becauseAuthenticateSession's after-middleware closure re-stashes the new hash into the session on the way out.
One last tweak: password reset (via the forgot-password flow) should invalidate other sessions for the same reason. The user isn't authenticated at reset time, so we update the column by query instead of through the model. Add this after confirmForgotPassword succeeds in ResetPasswordController::store:
use App\Models\User;
use Illuminate\Support\Str;
User::where('email', $data['email'])
->update(['password' => bcrypt(Str::random(60))]);
Same reasoning: if the user was still logged in anywhere, their session's stored hash no longer matches the column, so AuthenticateSession kills it on the next request.
Account Deletion
Deleting a user account when Cognito is involved raises a design question, and a regulatory one if you're subject to GDPR's right to erasure: what exactly should "delete my account" mean?
If the Cognito user pool is only used by this one app, the answer is simple: delete the user from both Cognito and the local database. But Cognito pools are often shared across multiple applications (that's the whole point of a centralized identity provider). In a multi-app setup, there are three options:
- Local only. Delete the local database row but leave the Cognito user untouched. The user can still log into other apps sharing the same pool. If they log into this app again,
firstOrCreatein the login closure would recreate their local record automatically. - Full deletion. Call
adminDeleteUseron the pool, then delete the local row. The user loses access to all apps sharing this pool. This is irreversible from an identity perspective. - User chooses. Offer both options in the UI: "Remove my account from this app" vs. "Delete my identity entirely."
The example app uses option 2 (full deletion) since it's a single-app demo. If you're sharing a pool across multiple apps, consider option 1 or 3.
The implementation verifies the password against Cognito first, deletes from Cognito, then deletes locally:
public function destroy(Request $request, CognitoClient $cognito): RedirectResponse
{
$data = $request->validate([
'password' => ['required', 'string'],
]);
$user = $request->user();
if (! $cognito->authenticate($user->email, $data['password'])) {
throw ValidationException::withMessages([
'password' => __('The password is incorrect.'),
]);
}
// Delete from Cognito first. If this fails, the local record stays intact.
try {
$cognito->adminDeleteUser($user->email);
} catch (CognitoIdentityProviderException $e) {
if ($e->getAwsErrorCode() !== 'UserNotFoundException') {
throw ValidationException::withMessages([
'password' => $e->getAwsErrorMessage() ?: $e->getMessage(),
]);
}
}
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
The order matters: Cognito deletion happens before the local delete. If the Cognito call fails, the local record stays intact and the user can try again. UserNotFoundException is swallowed because it means the user was already gone from Cognito, so we still proceed with the local cleanup.
Routes
Here's the complete routes/web.php:
<?php
use App\Http\Controllers\Auth\ConfirmSignUpController;
use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\ResetPasswordController;
use Illuminate\Support\Facades\Route;
Route::inertia('/', 'welcome')->name('home');
Route::middleware('guest')->group(function () {
// Password reset (overrides Fortify's built-in routes)
Route::get('forgot-password', [ForgotPasswordController::class, 'show'])->name('password.request');
Route::post('forgot-password', [ForgotPasswordController::class, 'store'])->middleware('throttle:6,1')->name('password.email');
Route::get('reset-password', [ResetPasswordController::class, 'show'])->name('password.reset');
Route::post('reset-password', [ResetPasswordController::class, 'store'])->middleware('throttle:6,1')->name('password.update');
// Sign-up confirmation (new; Cognito requires this step)
Route::get('confirm-signup', [ConfirmSignUpController::class, 'show'])->name('confirm-signup.show');
Route::post('confirm-signup', [ConfirmSignUpController::class, 'store'])->middleware('throttle:6,1')->name('confirm-signup.store');
Route::post('confirm-signup/resend', [ConfirmSignUpController::class, 'resend'])->middleware('throttle:3,1')->name('confirm-signup.resend');
});
Route::middleware(['auth'])->group(function () {
Route::inertia('dashboard', 'dashboard')->name('dashboard');
});
require __DIR__.'/settings.php';
By using the same route names Fortify expects (password.request, password.email, password.reset, password.update), middleware redirects and Inertia links keep working without changes. The confirm-signup routes are new, because Fortify has no concept of this step. The password change and account deletion routes live in routes/settings.php alongside the profile update routes from the starter kit.
Every POST route that calls Cognito gets explicit throttling. The confirmation-code endpoints use 6,1 (six requests per minute, enough for a fumbling user and tight enough to defeat a six-digit code brute force) and the resend endpoint is tighter at 3,1 because every call costs Cognito email quota. The login route is already throttled by Fortify's own per-email+IP login limiter (5/min), and register picks up the coarse throttle:fortify we wired into config/fortify.php above.
Note that the routes/settings.php group (password change, account deletion) sits behind only auth, not auth, verified. The starter kit defaults to verified there, but since App\Models\User doesn't implement MustVerifyEmail, the middleware short-circuits to "allow" and gives a false sense of security. Cognito's own UserNotConfirmedException at sign-in is what actually enforces confirmation, so we drop the decorative middleware.
The Complete Flow
Here's what happens end-to-end:
- Register. Fortify routes to
CreateNewUser→ validates input → callsCognitoClient::register()→ Cognito sends a 6-digit code to the email → local user created (no password) → session torn down → redirect to/confirm-signupwith a flash banner. - Confirm. User enters the code →
CognitoClient::confirmSignUp()→Auth::login($user)on the just-confirmed local row → redirect to/dashboard. - Login. Fortify calls our
authenticateUsingclosure →CognitoClient::authenticate()validates against Cognito → local user found (or created viafirstOrCreatefor SSO-style flows) → session started → redirect to dashboard. - Forgot password. User enters email →
CognitoClient::forgotPassword()→ Cognito sends a reset code → redirect to the reset form. - Reset password. User enters code + new password →
CognitoClient::confirmForgotPassword()→ redirect to login. - Change password. User enters current + new password →
authenticate()verifies the old one →adminSetUserPassword()sets the new one → localpasswordcolumn rotated to a new random marker, killing sibling sessions on their next request. - Delete account. User enters password →
authenticate()verifies →adminDeleteUser()removes from Cognito → local row deleted → session destroyed.
No custom guard driver. No trait swapping. No password hashes stored locally.
Things to Watch Out For
Confirmation emails land in spam. Cognito's default email sender (no-reply@verificationemail.com) has poor deliverability. For production, configure SES as the email provider in your user pool settings. During development, tell your testers to check spam, and always include a "resend code" button.
Cognito has a 50 emails/day limit on the default sender. Fine for development, but switch to SES for anything beyond that.
UserNotConfirmedException will crash your app if you don't handle it. When an unconfirmed user tries to log in, Cognito throws this exception instead of returning a normal auth failure. Our authenticateUsing closure catches it and redirects to the confirmation page. If you skip this, you'll get a 500 error.
One detail worth knowing: Cognito validates the password before it raises UserNotConfirmedException. That means a caller who triggers this path already knows the correct password, so redirecting to the confirmation page with the email in the URL isn't user enumeration. It's just helpful UX for the legitimate owner of the account. An attacker supplying a wrong password on an unconfirmed account gets NotAuthorizedException instead, which funnels into the generic failure message. AWS doesn't guarantee this ordering in writing, though, so we also softened the generic auth.failed translation to "These credentials are invalid, or your account has not yet been confirmed" as a belt-and-braces hint for users who typed the wrong password on an unconfirmed account.
Password validation happens in two places. Laravel validates the password against your local rules, then Cognito validates it against the pool's password policy (minimum length, uppercase, lowercase, numbers, symbols). If they're misaligned, users will pass local validation but get a Cognito error like "Password did not conform with policy". The Password::defaults() trick from the setup section keeps both sides in sync. If you tighten the pool policy in _terraform/cognito.tf, tighten that one line of PHP in the same PR.
Laravel's current_password validation rule won't work. It checks against the local database hash, which is null since Cognito owns passwords. Any form that asks for the current password (password change, account deletion) must verify it via CognitoClient::authenticate() instead.
Profile updates don't sync back to Cognito. The starter kit's profile page lets users update their name locally. In this version, the name change isn't pushed to Cognito's user attributes. If you need name changes reflected across apps sharing the pool, you'd add a CognitoClient::setUserAttributes() call to the profile update flow.
Email is read-only in the profile page. Earlier drafts of this integration let users change their email from the settings page. Don't do that. The login closure resolves Cognito users to local rows with User::firstOrCreate(['email' => $email], ...), so a mutable local email is enough to (a) silently lock a user out when the two sides fall out of sync, and (b) on a shared Cognito pool, escalate a short session compromise into full account takeover. An attacker who briefly controls victim A's session sets A's local email to an email B that the attacker owns in Cognito, then logs in as B and inherits A's local identity. A proper email-change flow requires a Cognito-side verification round-trip and shouldn't be shipped without one.
Hardening for production
The tutorial above bakes in a handful of defensive measures already: route-level rate limits on every Cognito-touching endpoint, Password::defaults() mirroring the pool policy, AuthenticateSession with a rotated column hash to invalidate sibling sessions on password change and reset, a belt-and-braces auth.failed translation, an immutable email in the profile page, and dropping the no-op verified middleware. None of those is exotic; all should be there on day one.
The following items are still on you before taking a Cognito-backed Laravel app to production.
Registration still leaks existence. The example keeps unique:users,email in CreateNewUser and forwards Cognito's UsernameExistsException message to the UI. Both tell an unauthenticated caller that a given email has an account. If you care about user enumeration, drop the unique rule, catch UsernameExistsException, and return a generic "check your email for next steps" response while a Cognito PreSignUp Lambda emails the original owner.
Remember-me is a Cognito bypass. Laravel's remember_token lives on the local users table. Once a user ticks "remember me," subsequent visits re-authenticate locally and never call Cognito. That means Cognito-side password resets, disables, or global sign-outs don't invalidate existing remember cookies; a stolen cookie is a full bypass until the user explicitly logs in again. Either drop the checkbox or add a Cognito status recheck on remember-cookie reauth.
Scrub Cognito error messages. Several controllers surface $e->getAwsErrorMessage() directly to the user. That pipes through existence hints ("user does not exist"), pool policy strings, and AWS request IDs. Wrap it in a small mapCognitoError() helper that switch-cases on getAwsErrorCode() and returns a curated user-facing string; log the original server-side. The tricky bit isn't the code; it's deciding which error codes deserve a distinct message and which should fall through to a generic "something went wrong."
Configuration flags that matter in production. APP_DEBUG=false (stack traces leak env including AWS_COGNITO_CLIENT_SECRET), SESSION_ENCRYPT=true (session payloads include the CSRF token and user ID), SESSION_SECURE_COOKIE=true (force Secure on HTTPS). The starter kit's .env.example is tuned for local development, not for a live deploy.
Behind a CDN or load balancer, configure trustProxies(). Every RateLimiter::for(...) key in this integration, plus the route-level throttles, uses $request->ip(). Without a trusted-proxy config, every request appears to come from the proxy IP and the per-IP rate limits collapse into one shared bucket, so one bot locks everyone out. Set $middleware->trustProxies(...) in bootstrap/app.php with your proxy's IP or CIDR.
Add audit logging. The example doesn't log failed login attempts, password changes, forgot-password triggers, or account deletions. In a production app, dispatch events (or log lines) for each and forward them to a tamper-resistant sink; it's your only post-hoc evidence after a suspected compromise.
Emails in URLs end up in access logs. The example carries email through /reset-password?email=… and /confirm-signup?email=… query strings, which means every webserver touching those requests logs the address. If that matters, pass the email via flash session instead.
Self-service email change. The profile page in the tutorial is deliberately read-only on email for the reasons outlined above. If you want to add it back, don't just re-enable the input. The flow has to call adminUpdateUserAttributes on Cognito, trigger a verification code to the new address, and only write the new email to the local row once that code has been confirmed. Both old and new addresses should be notified. Until the new email is verified, the user continues to sign in with the old one.
What's Not Covered
- MFA / Two-factor authentication. Cognito supports TOTP and SMS MFA, but the login flow becomes a two-step challenge/response that doesn't fit cleanly into
authenticateUsing. This is where a custom guard would earn its keep; we may cover it in a future post. - Single Sign-On across multiple apps. Cognito supports this natively (same pool, multiple app clients) and our
firstOrCreatein the login closure already handles the "user exists in Cognito but not locally" case, but a full SSO walkthrough deserves its own post. - OAuth / OIDC / Hosted UI. Cognito can act as a full OAuth 2.0 / OpenID Connect provider with its own hosted login pages. We use the
AdminInitiateAuthAPI flow instead, which keeps the UI in your Laravel app. - Token-based API authentication. We only covered session-based auth. Using Cognito's JWT access tokens for stateless API auth requires JWKS validation (fetching the public keys from
/.well-known/jwks.jsonand verifying token signatures). - Custom email sender (SES). Switching from Cognito's built-in email to Amazon SES for better deliverability and higher volume.
Conclusion
Compared to 2018, the integration is dramatically simpler. Fortify's authenticateUsing() hook eliminated the need for a custom guard entirely, and PHP 8.4's constructor promotion made the service class half the size. The total custom code, including the hardening bits from the later sections, is roughly:
- ~120 lines in
CognitoClient, the only class that touches AWS. - ~80 lines in
FortifyServiceProvider: the login closure, view registrations, and two rate limiters. - ~40 lines in
CreateNewUser(registration action). - ~40–60 lines each in the three guest auth controllers: confirmation, forgot password, reset password.
- ~70 lines in
SecurityController(password change with sibling-session invalidation). - ~110 lines in
ProfileController, mostly the account-deletion flow.
That's the entire backend integration. The frontend pages are standard Inertia React forms, nothing Cognito-specific about them.
The full example app (Laravel 13, PHP 8.4, Inertia React, Fortify, Cognito) along with the Terraform module is available on GitHub:

