pivotphp / security
Security middlewares for PivotPHP and any PSR-15 pipeline: CORS, trusted proxies, security headers, CSRF, JWT and rate limiting
Requires
- php: ^8.1
- psr/http-factory: ^1.0
- psr/http-message: ^1.1|^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- bepsvpt/secure-headers: ^9.1
- firebase/php-jwt: ^7.1.1
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^10.5.62|^11.5.50
- roave/security-advisories: dev-latest
- squizlabs/php_codesniffer: ^3.13.6
- symfony/rate-limiter: ^6.4|^7.0|^8.0
- yiisoft/csrf: ^2.2
Suggests
- bepsvpt/secure-headers: Required by Headers\SecurityHeadersMiddleware (^9.1)
- firebase/php-jwt: Required by Jwt\JwtAuthMiddleware and Jwt\JwtIssuer (^7.1.1)
- symfony/rate-limiter: Required by RateLimit\RateLimitMiddleware (^6.4|^7.0|^8.0)
- yiisoft/csrf: Required by Csrf\CsrfMiddleware (^2.2)
Provides
None
Conflicts
- bepsvpt/secure-headers: <9.1 || >=10.0
- firebase/php-jwt: <7.1.1 || >=8.0
- symfony/rate-limiter: <6.4 || >=9.0
- yiisoft/csrf: <2.2 || >=3.0
Replaces
None
This package is auto-updated.
Last update: 2026-10-10 19:45:17 UTC
README
Security middlewares for PivotPHP and any PSR-15 pipeline.
- One middleware per concern: CORS, trusted proxies, security headers, CSRF, JWT, rate limiting.
- Fail closed: a failed check returns
401/403/429and never reaches your handler. - Validated configuration: unsafe settings (
*origin with credentials, empty JWT secret,POSTdeclared "safe"...) throwInvalidConfigurationExceptionwhen the config is built. - No hand-rolled crypto: JWT, CSRF tokens, header building and rate limiting delegate to established libraries; this package provides the PSR-15 adapters.
The only required dependencies are the PSR interfaces. Each adapter needs its library, installed on demand:
| Middleware | Library | Install |
|---|---|---|
Cors\CorsMiddleware |
— | — |
Proxy\TrustedProxyMiddleware |
— | — |
Headers\SecurityHeadersMiddleware |
bepsvpt/secure-headers | composer require bepsvpt/secure-headers |
Csrf\CsrfMiddleware |
yiisoft/csrf | composer require yiisoft/csrf |
Jwt\JwtAuthMiddleware, Jwt\JwtIssuer |
firebase/php-jwt | composer require firebase/php-jwt |
RateLimit\RateLimitMiddleware |
symfony/rate-limiter | composer require symfony/rate-limiter |
Installation
composer require pivotphp/security
Middlewares that build responses take a PSR-17 ResponseFactoryInterface (e.g.
PivotPHP\Http\Factory\Psr17Factory or Nyholm\Psr7\Factory\Psr17Factory).
Recommended order
TrustedProxy → SecurityHeaders → CORS → RateLimit → (body parsing) → CSRF → JWT → routes
- TrustedProxy first, so every later middleware sees the real client IP.
- SecurityHeaders and CORS before anything that rejects, so
401/403/429responses still carry security and CORS headers (the browser can read the error). - CORS before authentication: preflight requests carry no credentials.
- Body parsing before CSRF, so form fields are visible.
CORS
use PivotPHP\Security\Cors\CorsConfig; use PivotPHP\Security\Cors\CorsMiddleware; $cors = new CorsMiddleware($responseFactory, new CorsConfig( allowedOrigins: ['https://app.example.com'], allowCredentials: true, ));
| Option | Default | Notes |
|---|---|---|
allowedOrigins |
[] |
Exact scheme://host[:port], lower case, no path. ['*'] allows any origin. |
allowedMethods |
GET, POST, PUT, PATCH, DELETE |
|
allowedHeaders |
Content-Type, Authorization |
Request headers accepted in preflight. |
exposedHeaders |
[] |
Response headers readable by the browser. |
allowCredentials |
false |
|
maxAge |
600 |
Preflight cache in seconds; null omits the header. |
Behaviour:
- Requests without
Originpass through untouched. - Preflight (
OPTIONS+Access-Control-Request-Method) is answered by the middleware:204withAccess-Control-Allow-*when origin, method and headers are allowed;403otherwise. The handler is never called. - Actual requests always reach the handler;
Access-Control-Allow-Originis added only for allowed origins.Vary: Originis added whenever the response depends on the origin.
⚠️ Security:
*withallowCredentials: trueis rejected at configuration. Thenullorigin (sandboxed iframes,file://) cannot be allowed. CORS does not protect the server — it only tells browsers what other origins may read; authenticate and authorize every request.
Trusted proxies
use PivotPHP\Security\Proxy\TrustedProxyConfig; use PivotPHP\Security\Proxy\TrustedProxyMiddleware; $proxy = new TrustedProxyMiddleware(new TrustedProxyConfig( trustedProxies: ['10.0.0.0/8', '2001:db8::/32'], )); // later: $request->getAttribute('client_ip')
| Option | Default | Notes |
|---|---|---|
trustedProxies |
[] |
IPv4/IPv6 addresses or CIDR ranges. |
header |
X-Forwarded-For |
Comma-separated IP chain. |
attribute |
client_ip |
Attribute receiving the resolved IP. |
The forwarding header is honoured only when REMOTE_ADDR is a trusted proxy. The chain is read
right to left, skipping trusted proxies; the first untrusted address is the client. A malformed entry
stops the walk. The attribute is not set when REMOTE_ADDR is missing or invalid.
⚠️ Security: never trust
X-Forwarded-Forfrom the internet. List only the proxies you run (load balancer, reverse proxy).
Security headers
use PivotPHP\Security\Headers\SecurityHeadersConfig; use PivotPHP\Security\Headers\SecurityHeadersMiddleware; $headers = new SecurityHeadersMiddleware(); // API defaults
Default headers (JSON API, OWASP REST guidance):
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
X-Permitted-Cross-Domain-Policies: none
| Option | Default | Notes |
|---|---|---|
hsts, hstsMaxAge, hstsIncludeSubDomains, hstsPreload |
true, 31536000, true, false |
Preload requires subdomains and max-age ≥ 1 year. |
frameOptions |
deny |
deny or sameorigin. |
referrerPolicy |
strict-origin-when-cross-origin |
Any standard value. |
contentSecurityPolicy |
default-src 'none'; frame-ancestors 'none' |
bepsvpt/secure-headers CSP array; null disables. |
overrides |
[] |
Raw bepsvpt/secure-headers config (Permissions-Policy, COOP/COEP/CORP, reporting...). |
new SecurityHeadersConfig(contentSecurityPolicy: [ 'default-src' => ['self' => true], 'img-src' => ['allow' => ['https://cdn.example.com']], ]);
Headers already set by the application are kept. The deprecated X-XSS-Protection is not sent.
CSP nonces are not supported (the library keeps them in static state shared across requests).
⚠️ Security: an HTML application needs a CSP that matches its assets; the default is meant for APIs. Enable HSTS only on HTTPS-only hosts.
CSRF
Needed when the API authenticates with cookies (session or JWT in a cookie). Pure bearer-token APIs are not exposed to CSRF.
use PivotPHP\Security\Csrf\CsrfMiddleware; use Yiisoft\Csrf\Hmac\HmacCsrfToken; use Yiisoft\Csrf\MaskedCsrfToken; $token = new MaskedCsrfToken(new HmacCsrfToken($identityGenerator, $secret, 'sha256', 3600)); $csrf = new CsrfMiddleware($responseFactory, $token); // render: $request->getAttribute('csrf_token')->getValue()
Token strategies come from yiisoft/csrf: SynchronizerCsrfToken (random token in a storage, e.g.
the session) or HmacCsrfToken (stateless, bound to an identity such as the session id). Wrap either
in MaskedCsrfToken when the token is rendered in HTML (BREACH mitigation).
Option (CsrfConfig) |
Default | Notes |
|---|---|---|
parameterName |
_csrf |
Form field. |
headerName |
X-CSRF-Token |
Header (SPA/AJAX). |
safeMethods |
GET, HEAD, OPTIONS |
POST/PUT/PATCH/DELETE cannot be declared safe. |
attribute |
csrf_token |
Attribute holding the token object. |
failureStatus |
403 |
Any 4xx. |
Every other method needs a valid token in the body field or header; otherwise the response is the failure status and the handler is not called.
⚠️ Security: keep
GETfree of side effects — safe methods are never checked. Combine withSameSitecookies.
JWT authentication
use PivotPHP\Security\Jwt\JwtAuthMiddleware; use PivotPHP\Security\Jwt\JwtConfig; $auth = new JwtAuthMiddleware($responseFactory, new JwtConfig( key: $_ENV['JWT_SECRET'], // ≥ 32 bytes for HS256 algorithm: 'HS256', publicPaths: ['/health', '/docs/*'], )); // in the handler: $request->getAttribute('user') — the claims as an array
| Option | Default | Notes |
|---|---|---|
key |
— | Secret (HS*) or public key / PEM (RS*, PS256, ES*, EdDSA). |
algorithm |
HS256 |
Exactly one algorithm is accepted. |
publicPaths |
[] |
fnmatch patterns that skip authentication. |
cookieName |
null |
Cookie read when Authorization is absent. |
issuer |
null |
Required iss. |
audience |
[] |
Accepted aud values (any match). |
leeway |
0 |
Clock skew in seconds. |
attribute |
user |
Attribute receiving the claims. |
Signature, exp, nbf and iat are verified by firebase/php-jwt (non-numeric time claims are
rejected); iss/aud are verified here. Failures return 401 with WWW-Authenticate: Bearer.
⚠️ Security: empty secrets, secrets shorter than the hash output (32/48/64 bytes) and unknown algorithms are rejected at configuration. Load keys from the environment or a secret store, never from source code.
Issuing tokens
JwtIssuer shares the JwtConfig used by the middleware, so issued tokens always match what is
verified (algorithm, iss, aud):
use PivotPHP\Security\Jwt\JwtIssuer; $issuer = new JwtIssuer($config); // HS*: signs with the configured secret // $issuer = new JwtIssuer($config, $privateKeyPem); // RS*/PS256/ES*/EdDSA: private key required $token = $issuer->issue(['sub' => $userId, 'role' => 'admin'], ttl: 3600);
iat, nbf and exp come from the clock and iss/aud from the configuration; passing them in the
claims throws InvalidConfigurationException. Generate HMAC secrets with bin2hex(random_bytes(32)).
Refresh tokens are an application concern (issue a second token with a longer ttl and a typ claim,
and check it in your refresh endpoint).
Rate limiting
use PivotPHP\Security\RateLimit\RateLimitConfig; use PivotPHP\Security\RateLimit\RateLimitMiddleware; use Symfony\Component\RateLimiter\RateLimiterFactory; $limiter = new RateLimiterFactory( ['id' => 'login', 'policy' => 'sliding_window', 'limit' => 5, 'interval' => '1 minute'], $storage, // e.g. CacheStorage over Redis $lockFactory, // symfony/lock — required for correct counts under concurrency ); $loginLimit = new RateLimitMiddleware($responseFactory, $limiter, new RateLimitConfig(keyPrefix: 'login:'));
Option (RateLimitConfig) |
Default | Notes |
|---|---|---|
keyPrefix |
'' |
One prefix per limit/route group. |
clientIpAttribute |
client_ip |
Set by TrustedProxyMiddleware; falls back to REMOTE_ADDR. |
exposeHeaders |
true |
X-RateLimit-Limit/X-RateLimit-Remaining on accepted responses. |
A fourth constructor argument accepts a custom key resolver
(fn (ServerRequestInterface $r): ?string, e.g. an API key). Over the limit: 429 with
Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining: 0; the handler is not called.
Use one middleware instance per limit (e.g. a stricter one on /login).
⚠️ Security: with
InMemoryStorageor without aLockFactory, limits are per process and concurrent requests can exceed them. Use a shared storage and a lock in production.
Testing
composer test # PHPUnit composer phpstan # PHPStan level 9 composer cs:check # PSR-12 composer quality:check # all of the above
License
MIT — see LICENSE.