Login, Logout & Token Refresh
This page covers how returning users authenticate, how tokens are rotated securely, and how the frontend manages sessions across page loads.
Email/Password Login
POST /api/v1/auth/login
Auth: None (public) Throttle: 5 requests per minute
Request
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Account email address |
password | string | Yes | Account password |
remember | boolean | No | Extend session duration (cookie mode) |
revoke_previous | boolean | No | Revoke all existing tokens before issuing new ones |
What Happens
The LoginUser action validates credentials using Laravel's Auth::attempt():
final class LoginUser
{
public function execute(LoginUserData $data): array
{
if (! Auth::attempt(
['email' => $data->email, 'password' => $data->password],
$data->remember
)) {
throw ValidationException::withMessages([
'email' => [__('auth.failed')],
]);
}
$user = User::where('email', $data->email)->firstOrFail();
return ['user' => $user->load('tenants')];
}
}
After successful authentication, the controller generates a token pair via RefreshAccessToken::createTokenPair().
Response (200)
{
"user": {
"id": 42,
"name": "Jane Smith",
"email": "jane@example.com",
"is_platform_admin": false,
"tenants": [
{
"id": "9f8a7b6c-...",
"name": "Acme Inc.",
"slug": "acme-inc"
}
]
},
"accessToken": "1|abc123...",
"refreshToken": "def456..."
}
Error Response (401)
{
"message": "These credentials do not match our records.",
"code": "INVALID_CREDENTIALS"
}
revoke_previous is true, all existing access and refresh tokens for the user are revoked before the new pair is issued. Use this when you want to enforce single-session behavior.OAuth Login
SaaS4Builders supports OAuth authentication via Google and GitHub using Laravel Socialite. The flow uses a popup window to avoid full-page redirects.
The Popup Flow
Frontend (parent) Frontend (popup) Backend OAuth Provider
───────────────── ──────────────── ─────── ──────────────
1. GET /oauth/providers
← ["google","github"]
2. window.open(popup)
GET /oauth/google/
redirect
← 302 ──────────────────────────────────────── Google consent
← callback URL
GET /oauth/google/
callback
├── getUser(google)
├── AuthenticateViaOAuth
│ ├── Find by oauth_id
│ ├── OR link by email
│ └── OR create new user
├── Cache code (60s TTL)
└── 302 → /auth/callback
?code=UUID
/auth/callback page
postMessage({
type: 'oauth-callback',
code: UUID
})
window.close()
3. Receives postMessage
POST /oauth/exchange
{ code: UUID }
Pull from cache
Cookie mode → session
Token mode → token pair
← { user, isNewUser,
accessToken?,
refreshToken? }
Provider Listing
GET /api/v1/auth/oauth/providers
Returns the list of enabled OAuth providers:
{
"providers": ["google", "github"]
}
Providers are enabled by setting the appropriate credentials in your environment (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET).
User Resolution
When the OAuth callback arrives, the AuthenticateViaOAuth action resolves the user through three branches:
final class AuthenticateViaOAuth
{
public function execute(OAuthUserData $data): array
{
return DB::transaction(function () use ($data): array {
// 1. Try to find existing user by OAuth provider + ID
$user = User::where('oauth_provider', $data->provider)
->where('oauth_provider_id', $data->oauthId)
->first();
$isNewUser = false;
if (! $user) {
// 2. Check if user exists with same email — link OAuth to existing account
$user = User::where('email', $data->email)->first();
if ($user) {
$user->update([
'oauth_provider' => $data->provider,
'oauth_provider_id' => $data->oauthId,
'avatar' => $data->avatar,
]);
} else {
// 3. Create new user + tenant
$isNewUser = true;
$user = User::create([
'name' => $data->name,
'email' => $data->email,
'oauth_provider' => $data->provider,
'oauth_provider_id' => $data->oauthId,
'avatar' => $data->avatar,
'email_verified_at' => now(), // OAuth emails are pre-verified
]);
// Create first tenant
$tenant = $this->createTenant->execute(
new CreateTenantData(name: $user->name."'s Organization"),
$user,
);
}
}
return ['user' => $user->load('tenants'), 'isNewUser' => $isNewUser];
});
}
}
Code Exchange
The callback stores a short-lived code in the cache (60-second TTL) and redirects to the frontend. The frontend popup sends the code to the parent window via postMessage, which then exchanges it:
POST /api/v1/auth/oauth/exchange
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | The UUID code from the OAuth callback |
public function exchange(
ExchangeOAuthCodeRequest $request,
RefreshAccessToken $refreshAction,
): JsonResponse {
$dto = $request->toDto();
$cached = Cache::pull("oauth_exchange:{$dto->code}");
if ($cached === null) {
return response()->json([
'message' => __('auth.oauth_code_expired'),
'code' => 'OAUTH_CODE_EXPIRED',
], 400);
}
$user = User::findOrFail($cached['user_id']);
$isStateful = EnsureFrontendRequestsAreStateful::fromFrontend($request);
if ($isStateful) {
Auth::guard('web')->login($user);
$request->session()->regenerate();
} else {
$tokens = $refreshAction->createTokenPair($user);
$response['accessToken'] = $tokens['accessToken'];
$response['refreshToken'] = $tokens['refreshToken'];
}
return response()->json($response);
}
OAUTH_CODE_EXPIRED. The frontend should handle this by prompting the user to try again.Password Reset
Password reset follows a two-step flow: request a link, then apply the new password.
Step 1: Request Reset Link
POST /api/v1/auth/forgot-password
Throttle: 5 requests per minute
| Field | Type | Required |
|---|---|---|
email | string | Yes |
Returns { "message": "We have emailed your password reset link." } (200) regardless of whether the email exists — this prevents email enumeration.
Step 2: Reset Password
POST /api/v1/auth/reset-password
Throttle: 5 requests per minute
| Field | Type | Required |
|---|---|---|
email | string | Yes |
token | string | Yes |
password | string | Yes |
password_confirmation | string | Yes |
Returns { "message": "Your password has been reset." } (200) on success, or { "message": "..." } (400) if the token is invalid or expired.
Both endpoints delegate to Laravel's built-in Password facade via dedicated Actions (SendPasswordResetLink, ResetPassword).
Token Refresh
POST /api/v1/auth/refresh
Auth: None (uses the refresh token itself for authentication) Throttle: 10 requests per minute
Request
| Field | Type | Required | Description |
|---|---|---|---|
refresh_token | string | Yes | The 64-character refresh token |
Token Rotation
The RefreshAccessToken action implements secure token rotation:
public function execute(string $refreshTokenString): array
{
$hashedToken = hash('sha256', $refreshTokenString);
$refreshToken = RefreshToken::where('token', $hashedToken)->first();
if (! $refreshToken) {
throw ValidationException::withMessages([
'refresh_token' => [__('auth.invalid_refresh_token')],
]);
}
// Token reuse detection — potential theft
if ($refreshToken->isRevoked()) {
$refreshToken->revokeFamily(); // Revoke ALL tokens in this family
throw ValidationException::withMessages([
'refresh_token' => [__('auth.refresh_token_reused')],
]);
}
if ($refreshToken->isExpired()) {
throw ValidationException::withMessages([
'refresh_token' => [__('auth.refresh_token_expired')],
]);
}
return DB::transaction(function () use ($refreshToken): array {
$user = $refreshToken->user;
// Revoke current token (rotation)
$refreshToken->revoke();
// Create new token in the same family
$newRefreshTokenString = Str::random(64);
RefreshToken::create([
'user_id' => $user->id,
'token' => hash('sha256', $newRefreshTokenString),
'family' => $refreshToken->family,
'expires_at' => now()->addDays(self::REFRESH_TOKEN_EXPIRATION_DAYS),
]);
$accessToken = $user->createToken('auth-token')->plainTextToken;
return [
'user' => $user->load('tenants'),
'accessToken' => $accessToken,
'refreshToken' => $newRefreshTokenString,
];
});
}
How It Works
- The incoming token is hashed and looked up in the
refresh_tokenstable. - If the token is revoked: the entire token family is revoked (theft detection), and a 401 is returned. This forces both the legitimate user and any attacker to re-authenticate.
- If the token is expired: a 401 is returned.
- If the token is valid: it is revoked, and a new token is created in the same family. A new access token is also generated.
Response (200)
{
"user": { "id": 42, "name": "Jane Smith", "email": "jane@example.com" },
"accessToken": "2|xyz789...",
"refreshToken": "ghi012..."
}
Error Responses
| Code | Meaning |
|---|---|
INVALID_REFRESH_TOKEN | Token not found or was revoked (family revoked on reuse) |
The refresh_tokens table stores: user_id, token (SHA-256 hashed), family (groups related tokens), expires_at, and revoked_at.
Logout
POST /api/v1/auth/logout
Auth: auth:sanctum + tenant.resolve
Logout revokes all tokens and ends the session:
public function logout(Request $request, LogoutUser $action): JsonResponse
{
$user = $request->user();
if ($user) {
$action->execute($user); // Deletes access token + revokes refresh tokens
}
// Clear the guard's cached user BEFORE invalidating the session.
// Without this, AuthenticateSession middleware stores the current
// user's password hash in the new session, causing a 401 on the
// next login by a different user.
Auth::guard('web')->logout();
if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();
}
return response()->json(['message' => __('auth.logout_success')]);
}
What happens on logout:
LogoutUseraction — Deletes the current Sanctum access token and revokes all refresh tokens for the user.- Guard logout — Clears the guard's cached user to prevent password hash mismatch on the next login.
- Session cleanup — Invalidates the session and regenerates the CSRF token (cookie mode only).
Frontend Logout
The auth store's logout() method handles the client side:
- Calls
POST /api/v1/auth/logout. - Calls
/api/studio/logoutto clear any Nuxt Studio session. - Clears all local state: user, tokens, session hints, related stores.
- Navigates to
/login.
Frontend Session Management
Auth Store Initialization
The auth store uses an idempotent initialize() method that runs once per page load:
Token mode:
- Load tokens from
localStorage. - If no token exists, mark as initialized (guest user) — skip the
/auth/mecall entirely. - If a token exists, call
bootstrap()which hitsGET /auth/me.
Cookie mode:
- Check for a
sessionActivehint inlocalStorage. - If no hint exists, mark as initialized (guest user) — skip the
/auth/mecall. - If the hint exists, call
bootstrap()which hitsGET /auth/me.
The session hint is a simple boolean flag set on login and cleared on logout. It prevents unnecessary 401 responses on guest pages.
Bootstrap: The /auth/me Call
GET /api/v1/auth/me is the single source of truth for frontend auth state. It returns:
{
"user": {
"id": 42,
"name": "Jane Smith",
"email": "jane@example.com",
"is_platform_admin": false,
"avatar": "https://...",
"roles": ["owner"],
"permissions": ["team.invite", "team.manage", "billing.manage"],
"tenants": [
{
"id": "9f8a7b6c-...",
"name": "Acme Inc.",
"user_role": "owner",
"has_active_subscription": true,
"has_billing_details": true,
"onboarding_completed_at": "2025-06-15T10:30:00+00:00"
}
]
},
"currentTenant": {
"id": "9f8a7b6c-...",
"name": "Acme Inc."
}
}
When an admin is impersonating a user, the response includes an additional impersonation field (see Impersonation for details).
Login Flow (Frontend)
The auth store's login() method handles both auth modes:
- Cookie mode: Fetch the CSRF cookie first (
GET /sanctum/csrf-cookie), thenPOST /auth/loginwithcredentials: 'include'. - Token mode:
POST /auth/loginwithout cookies. Save the returnedaccessTokenandrefreshTokentolocalStorage. - After login: Set the session hint, call
bootstrap()via/auth/meto load the full profile (roles, permissions, tenant context). - Redirect: Platform managers go to
/manager, regular users go to/dashboard.
useSession Composable
The useSession composable provides session lifecycle helpers:
const { checkAuth, requireAuth, requireGuest, startPeriodicRefresh, stopPeriodicRefresh } = useSession()
// In a page or middleware
await checkAuth() // Initialize auth if not done yet
await requireAuth() // Redirect to /login if not authenticated
await requireGuest() // Redirect to /dashboard if already authenticated
// Optional: refresh user profile periodically
startPeriodicRefresh(300_000) // Every 5 minutes
Auth Middleware
The auth.ts middleware runs on every navigation to protected pages:
- Skips during SSR (server-side rendering).
- Calls
authStore.initialize()if not already initialized. - If the user is not authenticated, redirects to
/loginwith aredirectquery parameter so the user returns to their intended page after login.
What's Next
- Registration & Onboarding — The signup flow with Stripe checkout
- Impersonation — How platform admins view the app as a tenant user
- Composables & Stores — How frontend state management works