Authentication with MonkeysLegion 2.0 + Next.js / React
A complete guide to implementing JWT-based registration, login, session persistence, and token refresh using the MonkeysLegion v2 API with a Next.js (or React) frontend.
Table of Contents
- API Endpoints Overview
- Backend Setup (MonkeysLegion)
- Frontend: API Client
- Frontend: Auth Context Provider
- Frontend: Register Page
- Frontend: Login Page
- Frontend: Protected Routes
- Token Refresh Flow
- Common Issues & Solutions
API Endpoints Overview
MonkeysLegion v2 exposes the following auth endpoints under the /api/v2/auth prefix:
Method
Endpoint
Auth
Description
POST
/auth/register
No
Create a new user + company
POST
/auth/login
No
Authenticate and get JWT tokens
POST
/auth/refresh
No
Exchange refresh token for new access token
GET
/auth/me
Yes
Get authenticated user profile
POST
/auth/logout
Yes
Invalidate tokens
POST
/auth/forgot-password
No
Request password reset
Response Shapes
Login Response β POST /auth/login
{
"data": {
"access_token": "eyJ0eXAiOiJKV1Q...",
"refresh_token": "eyJ0eXAiOiJKV1Q...",
"token_type": "Bearer",
"expires_in": 1800,
"user": {
"id": 1,
"email": "user@example.com",
"full_name": "John Doe"
}
}
}
Enter fullscreen mode Exit fullscreen mode
Register Response β POST /auth/register (HTTP 201)
{
"data": {
"message": "Registration successful",
"user": {
"id": 1,
"email": "user@example.com",
"full_name": "John Doe"
},
"company": {
"hash": "abc123",
"name": "Acme Inc."
},
"access_token": "eyJ0eXAiOiJKV1Q...",
"refresh_token": "eyJ0eXAiOiJKV1Q...",
"token_type": "Bearer",
"expires_in": 1800
}
}
Enter fullscreen mode Exit fullscreen mode
Me Response β GET /auth/me
{
"data": {
"id": 1,
"email": "user@example.com",
"full_name": "John Doe",
"phone": null,
"timezone": "UTC",
"status": "active",
"verified": false,
"two_factor": false,
"created_at": "2026-05-08T01:25:23+00:00"
}
}
Enter fullscreen mode Exit fullscreen mode
Backend Setup (MonkeysLegion)
1. Install the Framework
composer require monkeyscloud/monkeyslegion:^2.0.8
Enter fullscreen mode Exit fullscreen mode
Important: Version
2.0.8+is required. Earlier versions have a bug inDatabaseUserProviderwhere typed properties (DateTimeImmutable,int,bool) throwTypeErrorduring hydration from raw PDO strings.
2. Entity β app/Entity/User.php
<?php
declare(strict_types=1);
namespace App\Entity;
use MonkeysLegion\Auth\Contract\AuthenticatableInterface;
use MonkeysLegion\Query\Attribute\{Entity, Field, Id, Timestamps, Hidden, Fillable};
#[Entity(table: 'users')]
#[Timestamps]
class User implements AuthenticatableInterface
{
#[Id]
public private(set) int $id;
#[Field(type: 'string', length: 255, unique: true)]
#[Fillable]
public string $email;
#[Field(type: 'string', length: 255)]
#[Fillable]
public string $full_name;
#[Field(type: 'string', length: 255)]
#[Hidden]
public string $password_hash;
#[Field(type: 'integer', default: 1)]
public int $token_version = 1;
#[Field(type: 'datetime')]
public private(set) \DateTimeImmutable $created_at;
#[Field(type: 'datetime')]
public private(set) \DateTimeImmutable $updated_at;
// ββ AuthenticatableInterface ββ
public function getAuthIdentifier(): int { return $this->id; }
public function getAuthPassword(): string { return $this->password_hash; }
}
Enter fullscreen mode Exit fullscreen mode
3. Controller β app/Controller/Api/AuthController.php
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Service\AuthService;
use MonkeysLegion\Http\Attribute\{Route, Prefix, Throttle, Authenticated};
use MonkeysLegion\Http\Message\Response;
use Psr\Http\Message\ServerRequestInterface;
#[Prefix('/api/v2/auth')]
class AuthController
{
public function __construct(
private readonly AuthService $auth,
) {}
#[Route('POST', '/login', name: 'auth.login')]
#[Throttle(max: 5, per: 60)]
public function login(ServerRequestInterface $request): Response
{
$body = json_decode((string) $request->getBody(), true) ?? [];
if (empty($body['email']) || empty($body['password'])) {
return Response::json(['error' => 'Missing credentials'], 400);
}
$result = $this->auth->login($body['email'], $body['password']);
if ($result === null) {
return Response::json(['error' => 'Invalid credentials'], 401);
}
return Response::json([
'data' => [
'access_token' => $result['access_token'],
'refresh_token' => $result['refresh_token'],
'token_type' => 'Bearer',
'expires_in' => $result['expires_in'],
'user' => [
'id' => $result['user']->id,
'email' => $result['user']->email,
'full_name' => $result['user']->full_name,
],
],
]);
}
#[Route('POST', '/register', name: 'auth.register')]
#[Throttle(max: 3, per: 60)]
public function register(ServerRequestInterface $request): Response
{
$body = json_decode((string) $request->getBody(), true) ?? [];
if (empty($body['email']) || empty($body['password']) || empty($body['name'])) {
return Response::json(['error' => 'Missing required fields'], 400);
}
// Check for duplicate email
$existing = $this->auth->findByEmail($body['email']);
if ($existing !== null) {
return Response::json([
'error' => 'Validation failed',
'details' => ['email' => 'Email already registered'],
], 422);
}
$result = $this->auth->register($body);
return Response::json([
'data' => [
'message' => 'Registration successful',
'user' => [
'id' => $result['user']->id,
'email' => $result['user']->email,
'full_name' => $result['user']->full_name,
],
'access_token' => $result['access_token'],
'refresh_token' => $result['refresh_token'],
'token_type' => 'Bearer',
'expires_in' => $result['expires_in'],
],
], 201);
}
#[Route('GET', '/me', name: 'auth.me')]
#[Authenticated]
public function me(ServerRequestInterface $request): Response
{
// NOTE: The attribute is 'auth.user', NOT 'user'
$user = $request->getAttribute('auth.user');
return Response::json([
'data' => [
'id' => $user->id,
'email' => $user->email,
'full_name' => $user->full_name,
'status' => $user->status,
'created_at' => $user->created_at->format('c'),
],
]);
}
}
Enter fullscreen mode Exit fullscreen mode
Tip: Always use
$request->getAttribute('auth.user')β theAuthenticationMiddlewaresets the attribute asauth.user, notuser.
Frontend: API Client
Create a reusable API client that handles JWT tokens, automatic refresh, and typed requests.
src/lib/api.ts
"use client";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8088";
interface ApiOptions {
method?: string;
body?: unknown;
headers?: Record<string, string>;
token?: string | null;
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("mm_token");
}
async request<T = unknown>(path: string, options: ApiOptions = {}): Promise<T> {
const { method = "GET", body, headers = {}, token } = options;
const authToken = token ?? this.getToken();
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
// Auto-refresh on 401
if (res.status === 401) {
const refreshed = await this.refreshToken();
if (refreshed) {
return this.request<T>(path, { ...options, token: this.getToken() });
}
this.clearTokens();
throw new Error("Unauthorized");
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || err.message || "Request failed");
}
return res.json();
}
private async refreshToken(): Promise<boolean> {
const refresh = typeof window !==
"undefined" ? localStorage.getItem("mm_refresh") : null; if (!refresh) return false;
try {
const res = await fetch(`${this.baseUrl}/api/v2/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
});
if (!res.ok) return false;
const data = await res.json();
if (data.data?.access_token) {
localStorage.setItem("mm_token", data.data.access_token);
if (data.data.refresh_token) {
localStorage.setItem("mm_refresh", data.data.refresh_token);
}
return true;
}
return false;
} catch {
return false;
}
}
private clearTokens() { if (typeof window !== "undefined") { localStorage.removeItem("mm_token"); localStorage.removeItem("mm_refresh"); window.location.href = "/login"; } }
// ββ Auth Methods ββββββββββββββββββββββββββββββββββββββββββ login(email: string, password: string) { return this.request<{ data: { access_token: string; refresh_token: string; user: { id: number; email: string; full_name: string }; }; }>("/api/v2/auth/login", { method: "POST", body: { email, password } }); }
register(name: string, email: string, password: string, companyName: string) { return this.request<{ data: { access_token: string; refresh_token: string; user: { id: number; email: string; full_name: string }; }; }>("/api/v2/auth/register", { method: "POST", body: { name, email, password, company_name: companyName }, }); } }
export const api = new ApiClient(API_URL);
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#frontend-auth-context-provider)Frontend: Auth Context Provider
Wrap your app with `AuthProvider` to share auth state across all components.
### [](#-raw-srclibauthtsx-endraw-)`src/lib/auth.tsx`
"use client";
import React, { createContext, useContext, useEffect, useState, useCallback, type ReactNode, } from "react"; import { api } from "./api";
interface User { id: number; name: string; email: string; }
interface AuthState { user: User | null; loading: boolean; login: (email: string, password: string) => Promise<void>; register: ( name: string, email: string, password: string, companyName: string ) => Promise<void>; logout: () => void; }
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true);
// On mount: validate stored token by calling /me useEffect(() => { const token = localStorage.getItem("mm_token"); if (token) { api .request<{ data: { id: number; email: string; full_name: string } }>( "/api/v2/auth/me" ) .then((res) => setUser({ id: res.data.id, email: res.data.email, name: res.data.full_name, }) ) .catch(() => { localStorage.removeItem("mm_token"); localStorage.removeItem("mm_refresh"); }) .finally(() => setLoading(false)); } else { setLoading(false); } }, []);
const login = useCallback(async (email: string, password: string) => { const res = await api.login(email, password); localStorage.setItem("mm_token", res.data.access_token); localStorage.setItem("mm_refresh", res.data.refresh_token); // Map full_name β name for frontend consistency const u = res.data.user; setUser({ id: u.id, email: u.email, name: u.full_name }); }, []);
const register = useCallback( async ( name: string, email: string, password: string, companyName: string ) => { const res = await api.register(name, email, password, companyName); localStorage.setItem("mm_token", res.data.access_token); localStorage.setItem("mm_refresh", res.data.refresh_token); const u = res.data.user; setUser({ id: u.id, email: u.email, name: u.full_name }); }, [] );
const logout = useCallback(() => { localStorage.removeItem("mm_token"); localStorage.removeItem("mm_refresh"); setUser(null); window.location.href = "/login"; }, []);
return ( <AuthContext.Provider value={{ user, loading, login, register, logout }}> {children} </AuthContext.Provider> ); }
export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error("useAuth must be used within AuthProvider"); return ctx; }
Enter fullscreen mode Exit fullscreen mode
> **Note:** The API returns `full_name` but we map it to `name` in the frontend `User` interface for simplicity. This mapping happens in three places: `login`, `register`, and the `/me` response handler.
### [](#wire-into-layout-raw-srcapplayouttsx-endraw-)Wire Into Layout β `src/app/layout.tsx`
import { AuthProvider } from "@/lib/auth";
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <AuthProvider>{children}</AuthProvider> </body> </html> ); }
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#frontend-register-page)Frontend: Register Page
### [](#-raw-srcappauthregisterpagetsx-endraw-)`src/app/(auth)/register/page.tsx`
"use client";
import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { useAuth } from "@/lib/auth";
export default function RegisterPage() { const { register } = useAuth(); const router = useRouter(); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [companyName, setCompanyName] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setLoading(true);
try {
await register(name, email, password, companyName);
router.push("/login"); // Redirect to login after registration
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Registration failed");
} finally {
setLoading(false);
}
}
return ( <form onSubmit={handleSubmit}> <h1>Create Account</h1>
{error && <div className="error">{error}</div>}
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Full name" required />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required />
<input type="text" value={companyName} onChange={(e) => setCompanyName(e.target.value)} placeholder="Company name" required />
<button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Account"}
</button>
<p>Already have an account? <Link href="/login">Sign in</Link></p>
</form>
); }
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#frontend-login-page)Frontend: Login Page
### [](#-raw-srcappauthloginpagetsx-endraw-)`src/app/(auth)/login/page.tsx`
"use client";
import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { useAuth } from "@/lib/auth";
export default function LoginPage() { const { login } = useAuth(); const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setLoading(true);
try {
await login(email, password);
router.push("/"); // Redirect to dashboard
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
}
return ( <form onSubmit={handleSubmit}> <h1>Welcome Back</h1>
{error && <div className="error">{error}</div>}
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required />
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</button>
<p>Don't have an account? <Link href="/register">Create one</Link></p>
</form>
); }
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#frontend-protected-routes)Frontend: Protected Routes
Wrap dashboard pages with a layout that redirects unauthenticated users.
### [](#-raw-srcappdashboardlayouttsx-endraw-)`src/app/(dashboard)/layout.tsx`
"use client";
import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth";
export default function DashboardLayout({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); const router = useRouter();
useEffect(() => { if (!loading && !user) { router.push("/login"); } }, [user, loading, router]);
if (loading) { return <div>Loading...</div>; }
if (!user) return null;
return <>{children}</>; }
Enter fullscreen mode Exit fullscreen mode
### [](#using-the-user-in-components)Using the User in Components
"use client";
import { useAuth } from "@/lib/auth";
export default function ProfileCard() { const { user, logout } = useAuth();
return ( <div> <h2>Welcome, {user?.name}</h2> <p>{user?.email}</p> <button onClick={logout}>Sign Out</button> </div> ); }
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#token-refresh-flow)Token Refresh Flow
The API client handles token refresh **automatically**. Here's the sequence:
1. Browser makes a request (e.g. `GET /api/v2/messages`)
2. API Client attaches the stored `Bearer` token
3. If the API returns `401 Unauthorized` (token expired):
- Client sends `POST /api/v2/auth/refresh` with the refresh token
- On success: stores the new tokens and **retries the original request**
- On failure: clears all tokens and redirects to `/login`
### [](#token-lifetimes)Token Lifetimes
Token
Lifetime
Storage Key
Access Token
30 minutes
`localStorage("mm_token")`
Refresh Token
7 days
`localStorage("mm_refresh")`
> **Warning:** If both tokens are expired (user inactive for 7+ days), the client clears storage and redirects to `/login`.
* * *
## [](#common-issues-amp-solutions)Common Issues & Solutions
### [](#1-raw-typeerror-cannot-assign-string-to-property-of-type-datetimeimmutable-endraw-)1\. `TypeError: Cannot assign string to property ... of type DateTimeImmutable`
**Cause:** `DatabaseUserProvider` in MonkeysLegion β€ 2.0.7 does raw assignment from PDO strings to typed properties.
**Fix:** Upgrade to `monkeyscloud/monkeyslegion:^2.0.8` which includes `castValue()` type coercion in the hydrator.
* * *
### [](#2-raw-me-endraw-returns-raw-null-endraw-user-raw-requestgtgetattributeuser-endraw-is-null)2\. `/me` returns `null` user β `$request->getAttribute('user')` is null
**Cause:** The `AuthenticationMiddleware` sets the attribute as `auth.user`, not `user`.
**Fix:**
- $user = $request->getAttribute('user');
- $user = $request->getAttribute('auth.user');
Enter fullscreen mode Exit fullscreen mode
* * *
### [](#3-raw-422-unprocessable-content-endraw-on-register)3\. `422 Unprocessable Content` on register
**Cause:** The email is already registered. The API returns:
{ "error": "Validation failed", "details": { "email": "Email already registered" } }
Enter fullscreen mode Exit fullscreen mode
**Fix:** Use a different email, or parse the error details in the frontend to show a specific message.
* * *
### [](#4-raw-fullname-endraw-vs-raw-name-endraw-mismatch)4\. `full_name` vs `name` mismatch
**Cause:** The API returns `full_name` but your frontend `User` interface expects `name`.
**Fix:** Map the field in every place you read user data:
setUser({ id: u.id, email: u.email, name: u.full_name });
Enter fullscreen mode Exit fullscreen mode
* * *
### [](#5-cors-errors-from-raw-localhost3000-endraw-%E2%86%92-raw-localhost8088-endraw-)5\. CORS errors from `localhost:3000` β `localhost:8088`
**Cause:** The API needs CORS headers for cross-origin requests.
**Fix:** Add CORS middleware in your MonkeysLegion middleware stack:
// config/middleware.mlc MonkeysLegion\Http\Middleware\CorsMiddleware::class
Enter fullscreen mode Exit fullscreen mode
Or set the environment variable:
CORS_ORIGINS=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode
* * *
### [](#6-page-redirects-to-raw-login-endraw-on-refresh-despite-being-logged-in)6\. Page redirects to `/login` on refresh despite being logged in
**Cause:** The `/me` endpoint crashes (often the `DateTimeImmutable` bug), so the auth provider clears tokens and sets `user = null`.
**Fix:** Upgrade MonkeysLegion to `2.0.8+` and verify `/me` works:
curl -s http://localhost:8088/api/v2/auth/me
-H "Authorization: Bearer YOUR_TOKEN"
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#quick-reference)Quick Reference
Register a user via curl
curl -X POST http://localhost:8088/api/v2/auth/register
-H "Content-Type: application/json"
-d '{
"name": "John Doe",
"email": "john@example.com",
"password": "securepass123",
"company_name": "Acme Inc."
}'
Login
curl -X POST http://localhost:8088/api/v2/auth/login
-H "Content-Type: application/json"
-d '{"email": "john@example.com", "password": "securepass123"}'
Get current user
curl http://localhost:8088/api/v2/auth/me
-H "Authorization: Bearer <access_token>"
Refresh token
curl -X POST http://localhost:8088/api/v2/auth/refresh
-H "Content-Type: application/json"
-d '{"refresh_token": "<refresh_token>"}'
Enter fullscreen mode Exit fullscreen mode
[MonkeysLegion](https://monkeyslegion.com/)
