Essentials

Authentication

Built-in authentication endpoints, token flows, and how to extend them

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/users by default. See Identity & User Store for the model and how to swap or write your own.

Quick Start

Out of the box you get:

  1. Login endpoint (credentials + optional provider & remember-me)
  2. Token lifecycle (validate, refresh-token, refresh-permissions, logout)
  3. CSRF token endpoint (for SPAs using cookies)
  4. Auth middleware to protect routes — accepts JWT bearer tokens and API keys
  5. Browser sessions (opt-in HttpOnly cookie transport)
  6. API keys with scopes, IP allowlists, expiry, and rotation
  7. With glueful/users: verification & OTP, password recovery, 2FA, and GET /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:

EndpointMethodPurposeNotes
/auth/loginPOSTCredential login & session creationRate limited (5/min). Returns access + refresh token pair + user + expires_in + session_uuid, or sets session cookies with "auth_transport": "cookie"
/auth/validate-tokenPOSTValidate current access tokenRequires auth
/auth/refresh-tokenPOSTExchange refresh token for new pairBody contains refresh_token
/auth/refresh-permissionsPOSTRe-issue token after role/permission changesRequires auth
/auth/logoutPOSTInvalidate current access token + sessionRequires auth
/auth/session/refreshPOSTRotate browser-session cookiesOnly registered when SESSION_COOKIE_ENABLED=true
/auth/session/logoutPOSTRevoke session + clear cookiesOnly registered when SESSION_COOKIE_ENABLED=true
/csrf-tokenGETRetrieve a CSRF tokenSPA usage

From glueful/users:

EndpointMethodPurposeNotes
/auth/verify-emailPOSTSend verification code to emailProvide email
/auth/verify-otpPOSTVerify OTP sent to emailRate limited (3/min)
/auth/resend-otpPOSTResend OTPRate limited (2 per 2 min)
/auth/forgot-passwordPOSTInitiate password resetRate limited (3 per 15 min)
/auth/reset-passwordPOSTComplete password resetRate limited (5 per 15 min)
/meGETCurrent authenticated userRequires auth
/2fa/enable, /2fa/verify, /2fa/disablePOSTEmail-PIN two-factorOnly 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>

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 scopes claim or its scope claim (an array, or an OAuth-style space-delimited string such as "read:posts write:posts"), the user record's scopes, or an identity.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:

  1. POST /api/v1/auth/forgot-password – initiate (sends a code by email)
  2. 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 auth middleware applied
  • Check Authorization header present & formatted Bearer <token>
  • Confirm access token not expired (inspect expires_in returned at login)
  • Confirm a user store is enabled (without one, every login fails closed)

"Invalid token"?

  • Token revoked or malformed
  • JWT_KEY mismatch 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