Security Guide
This page focuses on the parts of Glueful security that are both implemented in the framework and useful in day-to-day application code. Standalone snippets assume you already have an ApplicationContext available as $context.
What Glueful Covers
- JWT-based authentication with persisted sessions
- CSRF protection for browser-based flows
- Rate limiting and abuse controls
- Security middleware and headers
- Production hardening and security CLI commands
Authentication and Sessions
Glueful’s authentication flow is centered around the auth manager, token manager, and session store.
use Glueful\Auth\Interfaces\SessionStoreInterface;
use Glueful\Auth\TokenManager;
$store = container($context)->get(SessionStoreInterface::class);
$tokenManager = app($context, TokenManager::class);
$user = [
'uuid' => $userUuid,
'email' => $email,
'username' => $username,
];
$accessTtl = $store->getAccessTtl('jwt', false);
$refreshTtl = $store->getRefreshTtl('jwt', false);
$tokens = $tokenManager->generateTokenPair($user, $accessTtl, $refreshTtl);
$store->create($user, $tokens, 'jwt', false);
For authenticated controllers, prefer the built-in BaseController state instead of manually decoding tokens:
use Glueful\Controllers\BaseController;
use Glueful\Http\Response;
use Symfony\Component\HttpFoundation\Request;
final class ProfileController extends BaseController
{
public function show(Request $request): Response
{
if ($this->currentUser === null) {
return $this->unauthorized('Authentication required');
}
return $this->success([
'user' => $this->currentUser->toArray(),
]);
}
}
Registration and Credential Validation
Core ships no user store. The users table, account lifecycle and its endpoints (/auth/verify-email, /auth/verify-otp, /auth/forgot-password, /auth/reset-password, /2fa/*) come from the glueful/users extension. It has no public sign-up endpoint, so a custom registration flow validates input with a DTO and writes through the extension's repository:
use Glueful\Auth\PasswordHasher;
use Glueful\Controllers\BaseController;
use Glueful\Extensions\Users\Repositories\UserRepository;
use Glueful\Http\Response;
use Glueful\Validation\Attributes\Rule;
use Glueful\Validation\Contracts\RequestData;
final class RegisterData implements RequestData
{
public function __construct(
#[Rule('required|string|min:3|max:50')]
public readonly string $username,
#[Rule('required|email|max:255')]
public readonly string $email,
#[Rule('required|string|min:12|max:255|confirmed')]
public readonly string $password,
#[Rule('required|string')]
public readonly string $password_confirmation,
) {}
}
final class RegistrationController extends BaseController
{
public function register(RegisterData $data): Response
{
$users = app($this->context, UserRepository::class);
try {
$uuid = $users->create([
'username' => $data->username,
'email' => $data->email,
// create() does not hash; always hash before storing
'password' => (new PasswordHasher())->hash($data->password),
]);
} catch (\InvalidArgumentException $e) {
// Duplicate email/username or invalid format
return Response::validation(['email' => [$e->getMessage()]]);
}
return $this->created(['uuid' => $uuid]);
}
}
Invalid input is rejected with a 422 before register() runs. Rate-limit this route like a login endpoint.
CSRF Protection
Use CSRF protection for browser forms and cookie-backed SPA flows. Add the csrf middleware to browser-facing write routes, retrieve a token from the built-in endpoint (served under your API prefix), and send it back in a header or form field.
GET /api/v1/csrf-token
Typical browser request:
POST /api/v1/profile
X-CSRF-Token: <token>
Tokens from GET /csrf-token and the session-bound csrf_token in the login response both validate against the csrf route middleware, so a client that just logged in can use the login token directly.
The middleware also accepts X-XSRF-Token, or a _token / csrf_token form field. With double-submit enabled, the token is also compared against the XSRF-TOKEN cookie.
The container builds the csrf middleware from the csrf block in config/security.php:
'csrf' => [
'enabled' => env('CSRF_PROTECTION_ENABLED', true),
'tokenLifetime' => env('CSRF_TOKEN_LIFETIME', 3600),
'useDoubleSubmit' => env('CSRF_DOUBLE_SUBMIT', false),
'allow_missing_origin' => env('CSRF_ALLOW_MISSING_ORIGIN', false),
'skip_for_bearer_auth' => env('CSRF_SKIP_FOR_BEARER_AUTH', true),
'exemptRoutes' => [
'auth/login', 'auth/register', 'auth/forgot-password', 'auth/reset-password',
'auth/verify-email', 'auth/verify-otp', 'webhooks/inbound/*', 'public/*', 'csrf-token',
],
],
Exempt routes are matched against the request path with and without the base path and API prefix, so auth/login exempts /api/v1/auth/login and webhooks/inbound/* covers /webhooks/inbound/stripe (but not the authenticated management API at /api/v1/webhooks/...). Full-path entries also work, and * is a wildcard.
The Origin/Referer check runs too. A write with neither header gets 403 CSRF_ORIGIN_MISMATCH, unless it carries Authorization: Bearer (with skip_for_bearer_auth) or allow_missing_origin is on. Cookie-transport requests never get the bearer relaxation, even though session_cookie injects an Authorization header for them. A key you leave out falls back to the middleware default (7200s lifetime, no double-submit, enabled, nothing exempt).
Per route, csrf:<lifetime>,<doubleSubmit>,<validateOrigin> overrides those values, for example ->middleware(['csrf:1800,true']). Route params arrive as strings; "1800", "true"/"false" and "1"/"0" are all read, and anything else keeps the configured value.
Since 1.91 the csrf block and csrf: route params take effect. Earlier releases built the middleware from constructor defaults and ignored route params, so tokens lived 7200s and no route was exempt. With the shipped config, tokens now expire after 3600s and the listed routes skip CSRF. The shipped exemptions now also match under /api/v1, so check none of them covers a route you meant to protect, and cookie-transport writes without Origin/Referer are now rejected. Review your app's security.csrf block when upgrading.
CORS
Allowed origins come from config/cors.php: CORS_ALLOWED_ORIGINS (comma-separated, or *) and CORS_ALLOWED_ORIGIN_PATTERNS (comma-separated PCRE patterns, passed to preg_match() as written, so include delimiters and avoid commas inside a pattern). In development, development_allow_all admits every origin.
CORS_ALLOWED_ORIGINS=https://app.example.com
CORS_ALLOWED_ORIGIN_PATTERNS="~^https://[a-z0-9-]+\.preview\.example\.com$~"
CORS_SUPPORTS_CREDENTIALS=true
Preflight and actual responses use the same origin matcher, so an origin admitted by a pattern gets Access-Control-Allow-Origin on the real response too. With credentials enabled, the specific origin is echoed with Vary: Origin and never sent as a wildcard. Anchor every pattern (~^…$~): an unanchored pattern such as ~example\.com~ also matches https://example.com.attacker.net. Before 1.91, pattern-matched origins passed preflight only and were then blocked by the browser. See CORS & CSRF.
Browser Sessions
Browser clients can authenticate with HttpOnly cookies instead of bearer tokens (see Browser Sessions). The refresh cookie (gf_refresh by default) is sent only to the session routes. Its path defaults to api_prefix() . '/auth/session' (for example /api/v1/auth/session), which is where the refresh and logout endpoints live. Set SESSION_COOKIE_REFRESH_PATH only if you serve those routes elsewhere.
Sign a browser in through the core login with "auth_transport": "cookie": with SESSION_COOKIE_ENABLED=true it sets the session cookies and returns user, session_uuid and a session-bound csrf_token, with no tokens in the body. See Authentication.
Session refresh and logout accept only same-origin requests. For an SPA on a sibling subdomain (Sec-Fetch-Site: same-site), list its exact origin in CORS_ALLOWED_ORIGINS (or a pattern, never *) and set CORS_SUPPORTS_CREDENTIALS=true; otherwise those calls are refused.
When upgrading from 1.90 or earlier, remove SESSION_COOKIE_REFRESH_PATH=/auth/session from existing .env files: that bare path never matched the prefixed routes, so browsers did not send the cookie.
Scoped Routes
#[RequireScope] restricts a route to credentials that carry a scope. Scopes within one attribute are alternatives; stacked attributes are all required:
use Glueful\Routing\Attributes\RequireScope;
#[RequireScope(['write:posts', 'admin:posts'])] // either one
#[RequireScope('publish:posts')] // and this one
public function publish(string $id): Response { }
Both credential types are checked with the same wildcard matcher:
- API keys use the key's scopes; a key with no scopes is unrestricted. See API Keys.
- JWTs use the
auth.useridentity's scopes, read from the token'sscopeclaim (an array, or an OAuth-style space-delimited string such as"read:posts write:posts"), else itsscopesclaim, else the user record'sscopes, plus anything anidentity.claims_provideradds. A JWT with no scopes is denied: unlike an API key, empty does not mean unrestricted.
Unauthenticated requests are always denied. Before 1.91, #[RequireScope] rejected every JWT request.
Rate Limiting
Rate limiting should be stricter on login, OTP, and password reset endpoints than on ordinary authenticated API traffic.
Recommended pattern:
- Login: low per-minute threshold
- OTP verification: stricter than login
- Password reset: low threshold with cooldown
- Authenticated application traffic: higher user-based limits
See Rate Limiting for route-level examples.
Security Headers and Middleware
Every response carries baseline headers automatically: X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin (unless the response sets its own). Framing protection, CSP and HSTS are per route, via the security_headers middleware, or are set by whatever terminates TLS.
Security-relevant middleware aliases:
authfor JWT/session-backed authenticationrate_limit/enhanced_rate_limit, or better,->rateLimit(...)/#[RateLimit]on the routecsrffor browser-facing writessecurity_headersfor CSP, framing and HSTS on browser-facing routesallow_ip,lockdown,require_scopefor network, emergency and scope restrictions (API keys and JWTs)
Production baseline:
APP_ENV=production
APP_DEBUG=false
JWT_KEY=replace-with-a-strong-secret
CSRF_PROTECTION_ENABLED=true
API_RATE_LIMITING_ENABLED=true
TOKEN_ALLOW_QUERY_PARAM=false
API_KEY_ALLOW_QUERY_PARAM=false
TRUSTED_PROXIES=10.0.0.0/8 # only if behind a proxy/load balancer
API_RATE_LIMITING_ENABLED=false switches rate limiting off entirely (before 1.91 it was ignored), so keep it true in production.
HTTPS is enforced at your web server or load balancer. There is no framework FORCE_HTTPS switch.
Security Commands
Use the CLI to verify production readiness and inspect vulnerabilities:
php glueful security:scan
php glueful security:vulnerabilities --format=json
php glueful system:production --check --score
Production Checklist
- Disable debug mode
- Use HTTPS everywhere (terminate TLS at the web server / load balancer and send HSTS there)
- Set a strong
JWT_KEY - Add
security_headersto browser-facing routes (baseline headers are automatic) - Enable rate limiting
- Restrict admin and internal routes
- Rotate secrets through environment management, not source control
- Monitor auth failures and unusual session activity