AppKit's security system is configured through config/security.php using a fluent SecurityConfigurator API. It covers firewalls, global access control rules, role hierarchy, CSRF protection, and session hardening. The design and flow are inspired by Symfony Security.
config/security.php returns a closure that receives a SecurityConfigurator instance.
// config/security.php
use Modufolio\Appkit\Security\SecurityConfigurator;
return function (SecurityConfigurator $security): void {
$security->firewall('main', [
'pattern' => '/',
'authenticators' => ['form_login'],
'entry_point' => '/login',
'logout' => [
'path' => '/logout',
'target' => '/',
],
]);
$security->roleHierarchy([
'ROLE_ADMIN' => ['ROLE_USER'],
]);
};Each firewall covers a path pattern and configures how authentication works for those routes.
$security->firewall('api', [
'pattern' => '/api',
'authenticators' => ['jwt'],
'stateless' => true,
]);Firewall options:
| Key | Type | Description |
|---|---|---|
pattern |
string |
Path prefix to guard. /admin matches /admin and everything below it. |
authenticators |
string[] |
Named authenticators from config/authenticators.php. |
entry_point |
string |
Where unauthenticated users are redirected. |
stateless |
bool |
true for API-style firewalls with no session. |
security |
bool |
Set to false to disable security for this firewall entirely. |
methods |
string[] |
Restrict the firewall to these HTTP methods. |
host |
string |
Restrict the firewall to this host (case-insensitive, plain match). |
ips |
string[] |
Restrict the firewall to these client IPs / CIDR ranges. |
logout.path |
string |
POST to this URL to log out. Requires a CSRF token — see below. |
logout.target |
string |
Redirect destination after logout. |
two_factor_path |
string |
Path for the 2FA code entry form. Defaults to /2fa. |
Firewall restrictions (Symfony-style). A firewall handles a request only when all of its declared restrictions match —
patternandmethodsandhostandips. A request that fails any one of them falls through to the next firewall whose restrictions do match. This makes a method-scoped public firewall safe: asecurity => falsefirewall limited tomethods => ['GET']exposes only reads, while writes to the same path fall through to an authenticated firewall.$security->firewalls([ // Public GET-only API for the menu tree. 'menu_read' => ['pattern' => '/api/menu', 'methods' => ['GET'], 'security' => false], // Everything else under /api (incl. writes to /api/menu) needs a token. 'api' => ['pattern' => '/api', 'authenticators' => ['jwt'], 'stateless' => true], ]);Invalid firewall configuration is rejected at boot (in
dev/test) against a schema — see Validating configuration below.
Logout is CSRF-protected. Two equivalent proofs are accepted, mirroring the general CSRF layer:
- HTML forms POST a
_csrf_tokenfield generated with the intention idlogout. This is a different id from login (authenticate) — a token minted for one will not validate the other.- fetch/XHR clients (SPAs, Inertia apps) send the firewall's session token (
csrf_token_id, defaultcsrf) via theX-CSRF-TokenorX-XSRF-Tokenheader — the same header they already attach to every other state-changing request.Without either,
AuthenticationExceptionis thrown.$token = $csrfTokenManager->getToken('logout')->getValue();<form method="post" action="/logout"> <input type="hidden" name="_csrf_token" value="<?= $token ?>"> </form>A GET request to the logout path is not handled and leaves the session authenticated.
Pattern syntax uses plain string matching, not regex. This prevents ReDoS attacks. Two forms:
/admin— matches any path that starts with/adminapi:0— matches paths where the first segment equalsapi
You can register several firewalls. AppKit matches each request to the first firewall whose pattern fits.
$security->firewalls([
'api' => [
'pattern' => '/api',
'authenticators' => ['jwt'],
'stateless' => true,
],
'main' => [
'pattern' => '/',
'authenticators' => ['form_login'],
'entry_point' => '/login',
'logout' => ['path' => '/logout', 'target' => '/'],
],
]);Firewall configuration is checked against a schema
(FirewallConfiguration) whenever it is loaded. Type errors and keys that would
silently fail open are rejected with a clear message — for example a methods
value that is not a list, or a non-callable csrf_validator.
Validation runs in dev and test (where config is authored) but is skipped
in prod for performance: the schema is not re-built on every production
request. To catch a bad config before it ships, run the check in CI or at deploy
time:
php bin/console security:validateIt validates both firewalls and access-control rules and exits non-zero on the first problem. To inspect the resolved configuration — firewalls, their restrictions, access-control rules, and the role hierarchy — use:
php bin/console debug:firewall # list everything
php bin/console debug:firewall main # detail one firewallDefine path-based rules that apply before any controller runs.
$security->accessControl('/admin', ['ROLE_ADMIN']);
$security->accessControl('/api/users', ['ROLE_ADMIN'], ['DELETE']);Parameters:
- Path pattern (same syntax as firewall patterns)
- Required roles (array)
- Methods (optional) — restrict the rule to specific HTTP verbs
- Options (optional) —
ips,requires_channel
Restrict by IP range:
$security->accessControl('/metrics', ['ROLE_ADMIN'], null, [
'ips' => ['127.0.0.1', '10.0.0.0/8'],
]);Require HTTPS:
$security->accessControl('/checkout', [], null, [
'requires_channel' => 'https',
]);An http request to an https-required path is redirected to the same URL
over https (preserving path and query), not hard-denied — the same request
over https is legitimate, so bouncing the user to an error page would be
wrong. The redirect is carried by InsecureChannelException and issued by the
exception handler.
Register multiple rules at once:
Unlike accessControl(), the bulk method stores each rule verbatim, so the rules must use associative keys (path, roles, optional methods) — positional arrays will silently match nothing and leave the paths unprotected:
$security->accessControlRules([
['path' => '/admin', 'roles' => ['ROLE_ADMIN']],
['path' => '/api', 'roles' => ['ROLE_USER'], 'methods' => ['GET', 'POST']],
]);For route-level access control, use #[IsGranted] instead. See Routing.
By default a request that matches no access-control rule is allowed through (the firewall still governs authentication). To flip this to fail-closed — deny anything not explicitly allowed by a rule — opt in:
$security->denyUnmatchedRequests();With this on, a request matching no rule is refused: an unauthenticated visitor
is sent to the entry point to log in, an authenticated one gets a hard 403.
Make sure every legitimately public path (assets, health checks, the login page)
has a matching publicPath() / accessControl() rule before enabling it.
Alongside ordinary ROLE_* attributes, rules and #[IsGranted] accept
trust-level attributes decided by how the request authenticated rather than by
the user's roles:
| Attribute | Granted when |
|---|---|
IS_AUTHENTICATED |
any authenticated token (including remember-me) |
IS_AUTHENTICATED_REMEMBERED |
a full login or a remember-me cookie |
IS_AUTHENTICATED_FULLY |
an interactive login this session (not remember-me) |
IS_IMPERSONATOR |
the request is impersonating another user (switch-user) |
use Modufolio\Appkit\Security\AuthenticationTrustResolverInterface as Trust;
// Reachable from a remember-me cookie...
$security->accessControl('/account', [Trust::IS_AUTHENTICATED_REMEMBERED]);
// ...but changing the password needs a fresh, full login.
$security->accessControl('/account/password', [Trust::IS_AUTHENTICATED_FULLY], ['POST']);When a rule requires IS_AUTHENTICATED_FULLY but the visitor is only
remembered, they are sent to log in again (step-up) rather than hard-denied —
the distinction between "authenticate more strongly" and "you may not do this"
is preserved.
Users with a higher role automatically have all roles below it.
$security->roleHierarchy([
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN'],
'ROLE_ADMIN' => ['ROLE_USER'],
'ROLE_USER' => ['ROLE_GUEST'],
]);AppKit caches up to 256 role combinations to keep role resolution fast in long-running workers.
CsrfTokenManager generates and validates CSRF tokens stored in the session.
Generating a token in a controller:
// Inject CsrfTokenManagerInterface via config/controllers.php
$token = $this->csrfTokenManager->getToken('my-form')->getValue();Using it in a template:
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">Validating manually:
$valid = $this->csrfTokenManager->validateToken('my-form', $request->getParsedBody()['_csrf_token'] ?? '');The FormLoginAuthenticator validates the CSRF token on POST /login automatically — but your login form must still render the token. Generate it with the token id authenticate and submit it in the _csrf_token field (both are configurable via the authenticator's csrf_token_id / csrf_parameter options):
$token = $this->csrfTokenManager->getToken('authenticate')->getValue();<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">Token details:
- 32 random bytes (64 hex characters)
- Validated with
hash_equals()— timing-safe - Maximum 50 tokens per session (FIFO eviction)
- Rotated automatically on successful login
AppKit applies these session protections by default:
HttpOnly— JavaScript cannot read the session cookieSameSite=Lax— mitigates most CSRF scenarios in modern browsers- Session migration on login — the session ID is rotated after authentication and the pre-login session storage is destroyed, so a fixed ID cannot be replayed as an authenticated session (OWASP A07:2021)
- CSRF tokens are cleared at login so any pre-authentication tokens become invalid
- Session invalidation on user change — on each request the session user is reloaded via the user provider, and the session is dropped if security-relevant state changed (revoked roles or a changed password). Implement
EquatableInterfaceon yourUserto control exactly which attributes trigger this; otherwise roles, password, and identifier are compared.
Add the Secure flag in production by setting COOKIE_SECURE=true in your environment.
If you also issue a remember-me cookie, read the same variable for its cookie_secure option (env()->getBool('COOKIE_SECURE', true)). Symfony's remember-me inherits this from the session config; AppKit's authenticators are configured independently, so nothing stops the two cookies from drifting apart. A remember-me cookie left with Secure on a plain-HTTP dev site is simply never sent back, and the opposite pairing leaks the credential over HTTP.
The firewall treats a failed login differently depending on who presented the credential: a person submitting a form (interactive), or the browser attaching a cookie on its own (ambient).
Interactive failures — someone submitted a login form — flash the exception's getMessageKey() and redirect to the entry point. getMessageKey() is the user-safe half of the exception contract: getMessage() may carry internal detail destined for logs ("User not found"), while the key is always fit to display ("Invalid credentials."). Two deliberate obfuscations apply:
- A user that does not exist produces the same message as a wrong password, so responses never reveal whether an email is registered.
- Account-status failures (locked, disabled, expired — thrown by
UserChecker) are also flashed as "Invalid credentials." — a distinct message would confirm to an attacker that the account exists. The original exception is preserved asgetPrevious()for logging.
Ambient failures — the browser presented a remember-me cookie on its own — are silent. Nobody typed anything, so a cookie that no longer validates (expired, password changed, secret rotated) is not a failed login attempt; flashing an error would accuse a visitor who never tried, on every request until the cookie expires. Instead the firewall expires the dead cookie on the response (Max-Age=0) and the request continues anonymously: remaining authenticators still run, public paths stay reachable, protected paths redirect to the entry point without a message.
A failed interactive login also expires any remember-me cookie riding along on the request, and a successful login wins over the expiry of a stale one — the fresh cookie is always issued after the clearing header.
AppKit's TokenUnserializer only deserialises a whitelist of classes from session-stored tokens. This prevents remote code execution via PHP unserialisation gadget chains.
Register your User entity before calling boot():
// In AppFactory::create()
TokenUnserializer::register(User::class);After boot() is called, the whitelist is frozen. No further classes can be added.
UserChecker runs pre-auth and post-auth checks on every login attempt. It covers three opt-in account states. Each is activated by implementing the corresponding interface on your User entity.
LockableUserInterface lets you block login for administratively suspended users.
use Modufolio\Appkit\Security\User\LockableUserInterface;
class User implements LockableUserInterface
{
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $lockedAt = null;
#[ORM\Column(nullable: true)]
private ?string $lockedReason = null;
public function isLocked(): bool { return $this->lockedAt !== null; }
public function getLockedAt(): ?\DateTimeImmutable { return $this->lockedAt; }
public function getLockedReason(): ?string { return $this->lockedReason; }
public function lock(string $reason): void
{
$this->lockedAt = new \DateTimeImmutable();
$this->lockedReason = $reason;
}
public function unlock(): void
{
$this->lockedAt = null;
$this->lockedReason = null;
}
}When isLocked() returns true, UserChecker throws LockedAccountException before credentials are checked. The getLockedReason() string is surfaced in the exception message shown to the user.
ExpirableUserInterface blocks login after a fixed date. Use this for contractor accounts, trial periods, or time-limited access.
use Modufolio\Appkit\Security\User\ExpirableUserInterface;
class User implements ExpirableUserInterface
{
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $accountExpiresAt = null;
public function isAccountExpired(): bool
{
return $this->accountExpiresAt !== null
&& $this->accountExpiresAt < new \DateTimeImmutable();
}
public function getAccountExpiresAt(): ?\DateTimeImmutable
{
return $this->accountExpiresAt;
}
}Set accountExpiresAt when creating the account. Once that date passes, login is blocked with AccountExpiredException.
CredentialsExpirableUserInterface forces a password change after a set period. UserChecker checks this after credentials are verified — the user authenticated successfully, but the session is not established until they reset their password.
use Modufolio\Appkit\Security\User\CredentialsExpirableUserInterface;
class User implements CredentialsExpirableUserInterface
{
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $credentialsExpireAt = null;
public function isCredentialsExpired(): bool
{
return $this->credentialsExpireAt !== null
&& $this->credentialsExpireAt < new \DateTimeImmutable();
}
public function getCredentialsExpireAt(): ?\DateTimeImmutable
{
return $this->credentialsExpireAt;
}
}A typical policy: extend credentialsExpireAt by 90 days on every successful password change.
SecurityHelper::generatePassword() creates a cryptographically random password. It guarantees at least one character from each class: lowercase, uppercase, digit, and special character.
use Modufolio\Appkit\Security\SecurityHelper;
$temporaryPassword = SecurityHelper::generatePassword(16); // length clamped to 8–64Pair it with CredentialsExpirableUserInterface when creating accounts on behalf of users:
$password = SecurityHelper::generatePassword();
$user->setPassword($hasher->hashPassword($user, $password));
$user->setCredentialsExpireAt(new \DateTimeImmutable()); // expired immediately
$entityManager->flush();
// email $password to the user — they must change it on first loginThese are your responsibility:
- Brute-force protection —
FileBruteForceProtectionandRedisBruteForceProtectionexist but must be wired manually intoFormLoginAuthenticator. See Authenticators. - HSTS — set
Strict-Transport-Securityin nginx, Caddy, or your CDN. - Content Security Policy — set
Content-Security-Policyat the edge. - X-Frame-Options — set in your reverse proxy configuration.
AppKit provides SwitchUserToken for programmatic user impersonation. There is no automatic query-parameter mechanism — you control the switch and exit yourself in controller actions.
Protect the switch route with #[IsGranted] so only authenticated admins can reach it. This is the same pattern Symfony's SwitchUserListener relies on — the firewall handles unauthenticated users before any switch logic runs, so a null-token check inside the controller is neither necessary nor appropriate (it would produce a 500 instead of a proper login redirect).
The string $firewall parameter is injected automatically by the Kernel — it contains the name of the active firewall for the current request.
use Modufolio\Appkit\Attributes\IsGranted;
use Modufolio\Appkit\Security\Token\SwitchUserToken;
#[IsGranted('ROLE_ADMIN')]
#[Route(path: '/users/{id}/switch', name: 'users_switch', methods: ['POST'])]
public function switchUser(
#[MapEntity] User $targetUser,
string $firewall,
): ResponseInterface {
// $this->tokenStorage->getToken() is guaranteed non-null here:
// #[IsGranted] already verified the user is authenticated.
$currentToken = $this->tokenStorage->getToken();
$refreshedTarget = $this->userProvider->refreshUser($targetUser);
$switchToken = new SwitchUserToken(
user: $refreshedTarget,
firewallName: 'main',
roles: $refreshedTarget->getRoles(),
originalToken: $currentToken,
);
$this->tokenStorage->setToken($switchToken);
$this->session->set('_security_' . $firewall, serialize($switchToken));
return Response::redirect($this->urlGenerator->generate('dashboard'));
}Check that the current token is a SwitchUserToken, retrieve the original token with getOriginalToken(), and restore it the same way.
use Modufolio\Appkit\Security\Token\SwitchUserToken;
#[Route(path: '/users/switch/exit', name: 'users_switch_exit', methods: ['POST'])]
public function exitSwitchUser(string $firewall): ResponseInterface
{
$currentToken = $this->tokenStorage->getToken();
if (!$currentToken instanceof SwitchUserToken) {
return Response::redirect($this->urlGenerator->generate('dashboard'));
}
$originalToken = $currentToken->getOriginalToken();
$this->tokenStorage->setToken($originalToken);
$this->session->set('_security_' . $firewall, serialize($originalToken));
return Response::redirect($this->urlGenerator->generate('dashboard'));
}SwitchUserToken exposes two ways to check whether the current session is impersonating:
use Modufolio\Appkit\Security\Token\SwitchUserToken;
$token = $this->tokenStorage->getToken();
$token instanceof SwitchUserToken; // true when impersonating
$token->isImpersonating(); // same check via method
$token->getAttribute('ROLE_PREVIOUS_ADMIN'); // true — set as an ATTRIBUTE, not a role
$token->getOriginalToken()->getUser(); // the original admin userTo gate a route or path on impersonation, use the IS_IMPERSONATOR
trust-level attribute rather than checking the token
type by hand — e.g. an "exit impersonation" banner action reachable only while
impersonating.
new SwitchUserToken(
user: UserInterface $user, // the user to impersonate
firewallName: string $firewallName, // must not be empty
roles: array $roles, // roles for the impersonated session
originalToken: TokenInterface $originalToken, // the token to restore on exit
)