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"}}}
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}}
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"}}
Backend Setup (MonkeysLegion)
1. Install the Framework
composer require monkeyscloud/monkeyslegion:^2.0.8
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; }
}
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'),
],
]);
}
}
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);
Frontend: Auth Context Provider
Wrap your app with AuthProvider to share auth state across all components.
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;
}
Note: The API returns
full_namebut we map it tonamein the frontendUserinterface for simplicity. This mapping happens in three places:login,register, and the/meresponse handler.
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>
);
}
Frontend: Register Page
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>
);
}
Frontend: Login Page
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>
);
}
Frontend: Protected Routes
Wrap dashboard pages with a layout that redirects unauthenticated users.
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}</>;
}
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>
);
}
Token Refresh Flow
The API client handles token refresh automatically. Here's the sequence:
- Browser makes a request (e.g.
GET /api/v2/messages) - API Client attaches the stored
Bearertoken - If the API returns
401 Unauthorized(token expired):- Client sends
POST /api/v2/auth/refreshwith the refresh token - On success: stores the new tokens and retries the original request
- On failure: clears all tokens and redirects to
/login
- Client sends
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 & Solutions
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. /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');
3. 422 Unprocessable Content on register
Cause: The email is already registered. The API returns:
{"error":"Validation failed","details":{"email":"Email already registered"}}
Fix: Use a different email, or parse the error details in the frontend to show a specific message.
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 });
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
Or set the environment variable:
CORS_ORIGINS=http://localhost:3000
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"
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>"}'