Add sign-in to a Laravel app with Nautilus
This guide adds email-and-password accounts to a Laravel app: a sign-up page, a sign-in page, a middleware that lets only signed-in users through, and a sign-out button. Nautilus, through its Gate API, keeps the users, their password hashes and their sessions. Laravel keeps Gate's session token in its own server-side session, so it never reaches the browser.
The code below was run end to end — sign-up, the protected page, sign-out, sign-in, a wrong password, a deactivated user, a second sign-up with the same address — with Laravel 13.36, PHP 8.4 and vernesoft/sdk 1.4.1.
What you need
- A Laravel app (
composer create-project laravel/laravel example-app). The SDK needs PHP 8.2 or later. - An API key with the
gatescope (Sign-in and users on the Keys page). See API Keys.
1. Install the SDK and add the key
composer require vernesoft/sdk -W
The -W is for Laravel 13: a Laravel 13 app created today installs Guzzle 8, the SDK requires Guzzle 7, and Laravel 13 accepts either. Without -W, Composer refuses the install; with it, Composer moves Guzzle to 7. On Laravel 12, which requires Guzzle 7, the flag is not needed.
VERNE_API_KEY=vrn_9f8a7...
'verne' => [
'key' => env('VERNE_API_KEY'),
],
Register one client for the whole app:
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Vernesoft\Verne;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Verne::class, fn () => new Verne(
apiKey: config('services.verne.key'),
));
}
public function boot(): void
{
//
}
}
2. Sign-up, sign-in, session check and sign-out
Everything that talks to Gate is one class. Two things in it are worth knowing before you read it:
- A flow is started with your key and finished without it.
createRegistrationFlow()andcreateLoginFlow()call Gate with your API key and return the flow as an array. The user's details are then posted as JSON to the flow's ownui.actionURL — no key, no cookie, no CSRF token — here with Laravel'sHttpclient. Registration takes two posts to that URL (the traits, then the password); a login takes one. The API reference has the raw requests. - The answer is a session token. A successful flow answers
200withsession_tokenandsession. The token is whatsessions()->check()takes, andsession.idis whatsessions()->revoke()takes. Both go into Laravel's session, which is stored on your server.
<?php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Vernesoft\Core\Errors\VerneApiException;
use Vernesoft\Verne;
class GateAuth
{
public function __construct(private Verne $verne) {}
/** Returns null on success, or the message to show the user. */
public function signUp(string $email, string $password): ?string
{
$flow = $this->verne->gate()->createRegistrationFlow();
$traits = ['traits.email' => $email, 'traits.locale' => 'en'];
// Step 1: the traits. A 400 that asks for the password is "accepted, continue".
$this->submit($flow, ['method' => 'profile'] + $traits);
// Step 2: the password, with the traits again.
$answer = $this->submit($flow, ['method' => 'password', 'password' => $password] + $traits);
return $this->keepSession($answer);
}
public function signIn(string $email, string $password): ?string
{
$flow = $this->verne->gate()->createLoginFlow();
$answer = $this->submit($flow, [
'method' => 'password',
'identifier' => $email,
'password' => $password,
]);
return $this->keepSession($answer);
}
/** The signed-in user's identity id, or null. Asks Gate on every call. */
public function currentUserId(): ?string
{
$token = session('gate.token');
if ($token === null) {
return null;
}
try {
$check = $this->verne->gate()->sessions()->check($token);
return $check->mfaSatisfied ? $check->identityId : null;
} catch (VerneApiException $e) {
// 401: expired, revoked, or the user was deactivated. 403 mfa_required: a second factor is owed.
if ($e->getStatus() === 401 || $e->getErrorCode() === 'mfa_required') {
return null;
}
throw $e;
}
}
public function signOut(): void
{
$sessionId = session('gate.session_id');
if ($sessionId !== null) {
try {
$this->verne->gate()->sessions()->revoke($sessionId);
} catch (VerneApiException $e) {
// 404: the session is already gone.
if ($e->getStatus() !== 404) {
throw $e;
}
}
}
session()->forget('gate');
session()->regenerate();
}
/** Posts one step of a flow to the flow's own `ui.action` URL. No API key, no cookie. */
private function submit(array $flow, array $body): Response
{
return Http::acceptJson()->post($flow['ui']['action'], $body);
}
private function keepSession(Response $answer): ?string
{
if ($answer->status() !== 200 || ! $answer->json('session_token')) {
return $this->messagesOf($answer);
}
session()->regenerate();
session()->put('gate.token', $answer->json('session_token'));
session()->put('gate.session_id', $answer->json('session.id'));
session()->put('gate.email', $answer->json('session.identity.traits.email'));
return null;
}
private function messagesOf(Response $answer): string
{
$messages = collect($answer->json('ui.messages', []))
->merge(collect($answer->json('ui.nodes', []))->flatMap(fn ($node) => $node['messages'] ?? []))
->pluck('text');
return $messages->implode(' ') ?: 'Something went wrong. Try again.';
}
}
A rejected password or an address that is already taken comes back as 400 with the reason in ui.messages (Registration); wrong credentials are 400 with message 4000006 (Completing a login). messagesOf() returns Kratos' own sentence for any of them.
session()->regenerate() on sign-in gives the browser a new Laravel session id, so a session id planted before sign-in is worth nothing after it. Sign-out revokes the session at Gate as well, not only in Laravel. It does not recall a signed token you may have issued from the session; those expire on their own within 15 minutes.
3. The middleware
<?php
namespace App\Http\Middleware;
use App\Services\GateAuth;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureGateSession
{
public function __construct(private GateAuth $gate) {}
public function handle(Request $request, Closure $next): Response
{
$userId = $this->gate->currentUserId();
if ($userId === null) {
return redirect()->route('sign-in');
}
$request->attributes->set('gate_user_id', $userId);
return $next($request);
}
}
It asks Gate on every request it guards. That is what makes a revoked session, a deactivated user or an expired session stop working on the very next request, with nothing cached in your app. Behind it, $request->attributes->get('gate_user_id') is the user's identity id — the key to keep your own data under.
4. Controller, routes and views
<?php
namespace App\Http\Controllers;
use App\Services\GateAuth;
use Illuminate\Http\Request;
class AuthController extends Controller
{
public function __construct(private GateAuth $gate) {}
public function showSignUp()
{
return view('auth', ['title' => 'Create an account', 'action' => route('sign-up')]);
}
public function signUp(Request $request)
{
$data = $request->validate(['email' => 'required|email', 'password' => 'required']);
$error = $this->gate->signUp($data['email'], $data['password']);
return $error
? back()->withInput($request->only('email'))->withErrors(['gate' => $error])
: redirect()->route('dashboard');
}
public function showSignIn()
{
return view('auth', ['title' => 'Sign in', 'action' => route('sign-in')]);
}
public function signIn(Request $request)
{
$data = $request->validate(['email' => 'required|email', 'password' => 'required']);
$error = $this->gate->signIn($data['email'], $data['password']);
return $error
? back()->withInput($request->only('email'))->withErrors(['gate' => $error])
: redirect()->route('dashboard');
}
public function signOut()
{
$this->gate->signOut();
return redirect()->route('sign-in');
}
}
<?php
use App\Http\Controllers\AuthController;
use App\Http\Middleware\EnsureGateSession;
use Illuminate\Support\Facades\Route;
Route::get('/sign-up', [AuthController::class, 'showSignUp']);
Route::post('/sign-up', [AuthController::class, 'signUp'])->name('sign-up');
Route::get('/sign-in', [AuthController::class, 'showSignIn'])->name('sign-in');
Route::post('/sign-in', [AuthController::class, 'signIn']);
Route::post('/sign-out', [AuthController::class, 'signOut'])->name('sign-out');
Route::middleware(EnsureGateSession::class)->group(function () {
Route::get('/dashboard', fn () => view('dashboard', [
'email' => session('gate.email'),
]))->name('dashboard');
});
<!doctype html>
<html lang="en">
<body>
<main>
<h1>{{ $title }}</h1>
<form method="POST" action="{{ $action }}">
@csrf
<input name="email" type="email" value="{{ old('email') }}" required placeholder="Email">
<input name="password" type="password" required placeholder="Password">
<button type="submit">{{ $title }}</button>
</form>
@error('gate') <p role="alert">{{ $message }}</p> @enderror
</main>
</body>
</html>
The dashboard shows the address from the sign-in answer, kept in the session, rather than asking Gate for the user on every page view: the identity endpoints are limited to 60 calls a minute per account (Rate Limits). The session check the middleware makes is a different endpoint.
5. Run it
php artisan serve
Open http://localhost:8000/sign-up, create an account, and you land on /dashboard. Sign out, and /dashboard sends you back to /sign-in. The new user is in Users in your dashboard, as your tenant's end user.
Where to go next
- Confirm the address and reset a forgotten password — Recovery and verification: the same flow pattern, with a code mailed to the user.
- Sign in with a code instead of a password — Sign in with a one-time code.
- Two-factor authentication — when
currentUserId()getsmfa_required, send the user through the step-up flow in MFA / TOTP. - Social login — Social Auth: the return URL and the one-time code exchange,
exchangeOidcCode()in the PHP SDK. - Teams and roles —
sessions()->check()already returns the user's organizations inorgs. See Organizations and Roles. - Change the password or the address — Settings Flow.
- See your users in the dashboard — what the dashboard shows: the users, their sessions, the audit log, and what it leaves to the API.
Sign-in and users is part of Nautilus by Verne Software, together with webhooks, scheduled jobs and Telegram sign-in, in one plan priced by active users and hosted in France. Create a free account or see the plans.