Authentication
Glueful ships a production‑oriented authentication stack in core: credential login, token validation, refresh and permission-refresh flows, logout, CSRF tokens, an opt-in browser-session (cookie) transport, and hardened API keys. Tokens are access + refresh pairs (OIDC‑style response shape) with server-side session storage, revocation, and rotation. Account lifecycle endpoints (email/OTP verification, password reset, 2FA, /me) come from the glueful/users extension.
You must enable a user store. Core is provider-agnostic and ships no concrete user store, so authentication is disabled until one is enabled — the api-skeleton enables
glueful/usersby default. See Identity & User Store for the model and how to swap or write your own.
Quick Start
Out of the box you get:
- Login endpoint (credentials + optional provider & remember-me)
- Token lifecycle (validate, refresh-token, refresh-permissions, logout)
- CSRF token endpoint (for SPAs using cookies)
- Auth middleware to protect routes — accepts JWT bearer tokens and API keys
- Browser sessions (opt-in HttpOnly cookie transport)
- API keys with scopes, IP allowlists, expiry, and rotation
- With
glueful/users: verification & OTP, password recovery, 2FA, andGET /me
Registration is NOT provided (by design you define how users are created). A custom registration example is included below.
Built-in Endpoints
Framework and extension auth routes are mounted under the API prefix — /api/v1 by default — so login is POST /api/v1/auth/login. Paths below are shown relative to that prefix.
Core:
| Endpoint | Method | Purpose | Notes |
|---|---|---|---|
| /auth/login | POST | Credential login & session creation | Rate limited (5/min). Returns access + refresh token pair + user + expires_in + session_uuid, or sets session cookies with "auth_transport": "cookie" |
| /auth/validate-token | POST | Validate current access token | Requires auth |
| /auth/refresh-token | POST | Exchange refresh token for new pair | Body contains refresh_token |
| /auth/refresh-permissions | POST | Re-issue token after role/permission changes | Requires auth |
| /auth/logout | POST | Invalidate current access token + session | Requires auth |
| /auth/session/refresh | POST | Rotate browser-session cookies | Only registered when SESSION_COOKIE_ENABLED=true |
| /auth/session/logout | POST | Revoke session + clear cookies | Only registered when SESSION_COOKIE_ENABLED=true |
| /csrf-token | GET | Retrieve a CSRF token | SPA usage |
From glueful/users:
| Endpoint | Method | Purpose | Notes |
|---|---|---|---|
| /auth/verify-email | POST | Send verification code to email | Provide email |
| /auth/verify-otp | POST | Verify OTP sent to email | Rate limited (3/min) |
| /auth/resend-otp | POST | Resend OTP | Rate limited (2 per 2 min) |
| /auth/forgot-password | POST | Initiate password reset | Rate limited (3 per 15 min) |
| /auth/reset-password | POST | Complete password reset | Rate limited (5 per 15 min) |
| /me | GET | Current authenticated user | Requires auth |
| /2fa/enable, /2fa/verify, /2fa/disable | POST | Email-PIN two-factor | Only when TWO_FACTOR_ENABLED=true |
Verification and reset emails are sent through the email notification channel, which the skeleton provides by enabling glueful/email-notification.
Login (Built-In)
username accepts a username or email (an email key also works):
{
"username": "[email protected]",
"password": "secret123",
"remember": true
}
Successful response shape (fields may vary depending on provider & configuration):
{
"success": true,
"message": "Login successful",
"data": {
"access_token": "<JWT>",
"refresh_token": "<refresh>",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"uuid": "user-uuid",
"email": "[email protected]",
"username": "user"
},
"session_uuid": "session-uuid",
"csrf_token": {
"token": "<csrf>",
"header": "X-CSRF-Token",
"field": "_token",
"expires_at": 1767225600
}
}
}
session_uuid identifies the server-side session (the JWT's sid claim). The csrf_token is bound to that session, so it validates on the first authenticated write without a separate GET /csrf-token call. It is present while CSRF protection is enabled.
If the user has 2FA enabled, login returns a pending second-factor challenge instead of tokens. Error responses align with middleware guidance (e.g. TOKEN_EXPIRED, SESSION_EXPIRED).
Protecting Routes
Use the auth middleware to require authentication. It accepts a JWT bearer token or an API key.
// routes/api.php
use Glueful\Routing\Router;
// Protected group
$router->group(['prefix' => 'v1', 'middleware' => ['auth']], function (Router $router) {
$router->get('/profile', [ProfileController::class, 'show']);
$router->post('/posts', [PostController::class, 'store']);
});
// Single protected route
$router->get('/v1/dashboard', [DashboardController::class, 'index'])
->middleware('auth');
// Anonymous allowed — the user is attached when a valid token is present
$router->get('/v1/feed', [FeedController::class, 'index'])
->middleware('auth:optional');
Making Authenticated Requests
Include the access token in the Authorization header:
curl http://localhost:8000/v1/profile \
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc..."
Accessing Current User
In a controller extending BaseController, $this->currentUser is the authenticated ?UserIdentity. Outside controllers (middleware, services that receive the request), read the auth.user request attribute, which auth always populates with a UserIdentity:
use Glueful\Controllers\BaseController;
use Glueful\Http\Response;
use Symfony\Component\HttpFoundation\Request;
class ProfileController extends BaseController
{
public function show(Request $request): Response
{
if ($this->currentUser === null) {
return $this->unauthorized('Not authenticated');
}
return $this->success([
'user' => $this->currentUser->toArray(),
]);
}
}
// In middleware:
$identity = $request->attributes->get('auth.user'); // ?Glueful\Auth\UserIdentity
Identity & the User Store
Authentication runs on a pluggable user store: core ships none, so a credential check returns null until you enable one (the api-skeleton enables glueful/users by default). The authenticated user is a canonical, immutable Glueful\Auth\UserIdentity whose accessors are methods — $user->uuid(), $user->email(), $user->roles(), $user->scopes().
→ See Identity & User Store for the full model: the UserProviderInterface contract, the login flow, writing or swapping a store, adding role/permission claims, and the account-status gate.
Adding Registration (Custom)
Registration is application-specific. Create users through your user store rather than writing the users table directly — with glueful/users, that's its UserRepository (which validates the username/email format and rejects duplicates, but expects an already-hashed password). The client then calls POST /auth/login:
use Glueful\Auth\PasswordHasher;
use Glueful\Controllers\BaseController;
use Glueful\Extensions\Users\Repositories\UserRepository;
use Glueful\Http\Response;
use Symfony\Component\HttpFoundation\Request;
class RegistrationController extends BaseController
{
public function register(Request $request): Response
{
$data = $this->getRequestData();
if ($error = $this->validateRequest($data, [
'email' => 'required|email|max:255',
'password' => 'required|max:255',
'username' => 'required|max:100',
])) {
return $error;
}
$users = app($this->getContext(), UserRepository::class);
try {
$uuid = $users->create([
'username' => $data['username'],
'email' => $data['email'],
'password' => (new PasswordHasher())->hash($data['password']),
]);
} catch (\InvalidArgumentException $e) {
return $this->validationError(['email' => $e->getMessage()]);
}
return $this->created(['uuid' => $uuid], 'Account created — please log in');
}
}
Register the controller in AppServiceProvider::services() and add a route such as POST /v1/auth/register (rate-limit it — see below).
Token Lifetimes & Configuration
Token settings live in config/session.php:
return [
'access_token_lifetime' => env('ACCESS_TOKEN_LIFETIME', 3600), // 1 hour
'refresh_token_lifetime' => env('REFRESH_TOKEN_LIFETIME', 604800), // 7 days
'remember_expiration' => env('REMEMBER_TOKEN_LIFETIME', 2592000), // 30 days ("remember": true)
'jwt_key' => env('JWT_KEY'), // signing secret (required)
'jwt_algorithm' => env('JWT_ALGORITHM', 'HS256'),
];
Values are consumed by TokenManager & providers. Refresh tokens are opaque (stored server-side); access tokens are JWT.
Refresh Token
Exchange a refresh token for a new pair:
POST /api/v1/auth/refresh-token
Content-Type: application/json
{
"refresh_token": "<refresh>"
}
Response:
{
"success": true,
"message": "Token refreshed successfully",
"data": {
"access_token": "<new_access>",
"refresh_token": "<new_refresh>",
"expires_in": 3600
}
}
Logout
POST /api/v1/auth/logout
Authorization: Bearer <access_token>
Response: { "success": true, "message": "Logged out successfully" }
Invalidates access token + associated session.
Validate Token
POST /api/v1/auth/validate-token
Authorization: Bearer <access_token>
Returns { user, is_valid: true } if token active.
Refresh Permissions
Re-issues token after permission/role changes:
POST /api/v1/auth/refresh-permissions
Authorization: Bearer <access_token>
Browser Sessions (Cookie Transport)
For browser clients that shouldn't hold tokens in JavaScript, enable the opt-in cookie transport with SESSION_COOKIE_ENABLED=true. The access and refresh credentials live in HttpOnly, Secure, SameSite=Lax cookies. API clients keep using Authorization: Bearer unchanged, and both reach the same auth middleware. Put session_cookie before auth on routes that should accept the cookie:
$router->get('/v1/account', [AccountController::class, 'show'])
->middleware(['session_cookie', 'auth']);
To sign a browser in, send "auth_transport": "cookie" with the credentials to the core login:
{
"username": "[email protected]",
"password": "secret123",
"auth_transport": "cookie"
}
The response sets the session cookies and its body carries user, expires_in, session_uuid and the session-bound csrf_token, but no access_token or refresh_token. While the transport is disabled, auth_transport is ignored and login returns tokens as usual.
POST /auth/session/refresh rotates both cookies and POST /auth/session/logout revokes the session and clears them. The refresh cookie's path defaults to api_prefix() . '/auth/session' (/api/v1/auth/session), so it reaches those routes without extra configuration. Set SESSION_COOKIE_REFRESH_PATH only if your session routes are served somewhere else. Cookie-authenticated writes are CSRF-exposed, so require a CSRF token on them.
The refresh and logout endpoints accept only same-origin browser requests. An SPA on a sibling subdomain (app.example.com calling api.example.com) sends Sec-Fetch-Site: same-site, which passes only when its Origin is explicitly allowed with credentials: list it in CORS_ALLOWED_ORIGINS (or a pattern; * doesn't count) and set CORS_SUPPORTS_CREDENTIALS=true.
Since 1.91: the refresh cookie's default path includes the API prefix. Before, it was a bare /auth/session, so browsers never sent it to the refresh and logout endpoints. If you set SESSION_COOKIE_REFRESH_PATH=/auth/session in .env to work around that, remove the line.
→ See Browser Sessions for issuing cookies from a custom login handler, optional mode, and every setting.
While the transport is disabled (the default), the /auth/session/* routes are not registered at all.
CSRF
The csrf middleware is configured by the csrf block in config/security.php: CSRF_PROTECTION_ENABLED, CSRF_TOKEN_LIFETIME (3600s), CSRF_DOUBLE_SUBMIT, and an exemptRoutes list (auth/login, webhooks/inbound/*, public/*, …). Clients send a token in the X-CSRF-Token header, either the csrf_token returned by login or one fetched from GET /csrf-token. Both validate against the csrf route middleware. exemptRoutes entries are relative paths and match under the API prefix (auth/login exempts /api/v1/auth/login); full-path entries also work.
The middleware also checks Origin/Referer. A request with neither header is rejected with 403 CSRF_ORIGIN_MISMATCH unless it carries an Authorization: Bearer header (CSRF_SKIP_FOR_BEARER_AUTH) or CSRF_ALLOW_MISSING_ORIGIN=true. A cookie-transport request never counts as bearer, even though session_cookie injects the header.
Since 1.91 that block is applied. Earlier releases built the middleware from constructor defaults, so tokens lived 7200s and no route was exempt. The shipped exemptions (auth/login, webhooks/inbound/*, public/*, …) now apply under /api/v1, so check none of them matches a route you meant to protect. Review your app's security.csrf block before upgrading.
API Keys
For server-to-server clients, issue API keys instead of user tokens. Keys are stored hashed in the core api_keys table. They carry scopes, an optional IP allowlist and expiry, and are environment-prefixed (gf_live_* / gf_test_*):
php glueful apikey:create --user=<user-uuid> --name="CI deploy" \
--scopes="read:posts,write:posts" --expires=+1year
php glueful apikey:rotate <key-uuid> --grace=24 # old key keeps working for 24h
Clients send the key as X-API-Key: gf_live_… (preferred) or Authorization: ApiKey gf_live_…, and the standard auth middleware accepts it.
→ See API Keys for listing, rotation, revocation, IP allowlists and the programmatic ApiKeyService.
Scopes
Require scopes per route with #[RequireScope], which auto-attaches the require_scope middleware:
use Glueful\Routing\Attributes\{Get, Post, RequireScope};
#[Get('/v1/posts')]
#[RequireScope('read:posts')]
public function index(): Response { /* ... */ }
#[Post('/v1/posts')]
#[RequireScope(['write:posts', 'admin:posts'])] // any of these…
#[RequireScope('publish:posts')] // …and this
public function store(): Response { /* ... */ }
Scope patterns support * wildcards (read:*). For file-defined routes use ->middleware(['auth', 'require_scope:read:posts']).
Both credential types can pass these routes:
- API keys are checked against the key's scopes. A key with no scopes is unrestricted.
- JWTs are checked against the identity's scopes. Those come from the token's
scopesclaim or itsscopeclaim (an array, or an OAuth-style space-delimited string such as"read:posts write:posts"), the user record'sscopes, or anidentity.claims_provider(see Identity). Unlike a key, a JWT with no scopes is denied.
Since 1.91: JWT requests can pass #[RequireScope] routes. Earlier releases rejected every JWT on them, so they effectively required an API key.
Role & Permission Access
Gate controller methods declaratively. The gate_permissions middleware is attached automatically. Full role and permission management comes from the optional glueful/aegis RBAC extension. Without it, the framework Gate decides, using the roles and voters in config/permissions.php:
use Glueful\Auth\Attributes\{RequiresPermission, RequiresRole};
#[RequiresPermission('posts.publish')]
public function publish(string $uuid): Response { /* ... */ }
#[RequiresRole('editor')]
#[RequiresRole('reviewer')] // stacked roles: any one of them is enough
public function review(string $uuid): Response { /* ... */ }
Roles from the method, its class and parent classes form one set, so a method-level role widens a class-level one. Stacked #[RequiresPermission] attributes still require all of the permissions. Use them, or a single combined role, when a route needs several things at once. Since 1.91: stacked #[RequiresRole] attributes mean "any one of". Earlier releases enforced them as "all of", contrary to the attribute's own documentation.
The same Gate backs requirePermission() in controllers. In a Gate-only app (no permission provider installed), it now admits users whose Gate roles or voters allow it, where earlier releases refused every call with "Permission system unavailable".
For a simple check against roles carried on the identity:
if (!in_array('admin', $this->currentUser?->roles() ?? [], true)) {
return $this->forbidden('Admin access required');
}
Built-In Admin Gate
auth accepts an admin parameter that requires an admin principal (a user with the admin role or an admin flag):
$router->get('/v1/admin/stats', [AdminController::class, 'stats'])
->middleware('auth:admin');
Custom Role Middleware
Implement RouteMiddleware, keep the mixed ...$params variadic, and read the identity auth attached:
use Glueful\Http\Response;
use Glueful\Routing\RouteMiddleware;
use Symfony\Component\HttpFoundation\Request;
final class RequireRolesMiddleware implements RouteMiddleware
{
public function handle(Request $request, callable $next, mixed ...$params): mixed
{
$identity = $request->attributes->get('auth.user'); // ?UserIdentity
if ($identity === null) {
return Response::unauthorized('Authentication required');
}
foreach ($params as $role) {
if (!in_array($role, $identity->roles(), true)) {
return Response::forbidden('Missing required role: ' . $role);
}
}
return $next($request);
}
}
Middleware is resolved from the container, so register the class in your provider's services() and reference it by class name, after auth:
$router->post('/v1/content/review', [ContentController::class, 'review'])
->middleware(['auth', RequireRolesMiddleware::class . ':editor,reviewer']);
Resource Ownership
Pattern example inside a controller:
if ($this->currentUser === null) {
return $this->unauthorized();
}
$db = db($this->getContext());
$post = $db->table('posts')->where(['uuid' => $postUuid])->first();
if ($post === null) {
return $this->notFound('Post not found');
}
if (($post['user_uuid'] ?? null) !== $this->currentUser->uuid()) {
return $this->forbidden('You can only edit your own posts');
}
$db->table('posts')
->where(['uuid' => $postUuid])
->update($payload);
return $this->success(null, 'Post updated');
Password Reset
With glueful/users enabled, use its endpoints:
POST /api/v1/auth/forgot-password– initiate (sends a code by email)POST /api/v1/auth/reset-password– complete reset
The extension handles validation, hashing, session consistency, and email dispatch. Operators can also reset a password from the CLI with php glueful security:reset-password (shipped by glueful/users). Implement a custom flow only if you need alternative storage or multi-factor requirements.
Profile & Account Changes
glueful/users serves the current user at GET /api/v1/me. For profile or password changes, go through the store's repository (UserRepository::updateProfile(), or setNewPassword() with an already-hashed password) instead of updating the users table yourself — the table and its invariants belong to the extension, and a custom store may not have one at all.
Best Practices
Never Store Plain Passwords
use Glueful\Auth\PasswordHasher;
// ✅ Always hash passwords
$hash = (new PasswordHasher())->hash($password);
// ❌ Never store plain text
$userRecord['password'] = $password;
Use HTTPS in Production
Terminate TLS at your web server or load balancer and redirect HTTP there. Browser-session cookies are Secure by default and won't be sent over plain HTTP.
Validate Strong Passwords
use Glueful\Validation\Support\RuleParser;
use Glueful\Validation\Validator;
use Glueful\Validation\ValidationException;
$rules = (new RuleParser())->parse([
'password' => 'required|min:8|regex:/[a-z]/|regex:/[A-Z]/|regex:/[0-9]/'
]);
$validator = new Validator($rules);
$errors = $validator->validate($data);
if ($errors !== []) {
throw new ValidationException($errors);
}
Rate Limit Auth Endpoints
The built-in login is already limited to 5 requests per minute. Limit your own auth routes the same way (the router attaches the rate_limit middleware for you):
$router->post('/v1/auth/register', [RegistrationController::class, 'register'])
->rateLimit(5, 1); // 5 attempts per minute, per IP
Troubleshooting
"Unauthorized" on protected routes?
- Ensure
authmiddleware applied - Check Authorization header present & formatted
Bearer <token> - Confirm access token not expired (inspect
expires_inreturned at login) - Confirm a user store is enabled (without one, every login fails closed)
"Invalid token"?
- Token revoked or malformed
JWT_KEYmismatch between environments- User session removed (logout, cleanup) – re-login
User null?
- Middleware missing OR token failed validation
- Using refresh token instead of access token
- Provider mismatch (multi-provider scenario)
"Insufficient scope"?
- The route has
#[RequireScope]and the credential lacks that scope. For a JWT, check that the token or identity carries scopes at all: a JWT with no scopes is denied, while a key with none is unrestricted.
Next Steps
- Routing – Apply middleware & groups
- API Keys and Browser Sessions – the two non-bearer transports in depth
- Controllers – Structure request handling
- Validation – Harden input rules
- Extensions –
glueful/users, RBAC withglueful/aegis
Typed Requests & Responses
Declare request and response contracts as PHP classes. The router validates input before your handler runs, envelopes output after it returns, and the same types drive the OpenAPI spec.
Identity & User Store
How Glueful models who your users are — the provider-agnostic identity contract, the pluggable user store, and claims.