If your Symfony API is public-facing, weak authentication is your #1 risk. In this guide, I’ll show practical, copy-paste fixes for JWT, API keys, rate limiting, and secure defaults—plus how to quickly check your site with a Website Vulnerability Scanner online free.
Why “weak authentication” happens in Symfony
Common pitfalls I see in audits:
- Accidentally allowing
PUBLIC_ACCESS
on/api/*
- Long-lived tokens (no rotation, no refresh)
- Missing IP/credential throttling
- Treating API keys like passwords (no scope/expiry/rotation)
- Leaking secrets via verbose error messages or debug toolbars
Quick win: run your site through our free Website Security Scanner to baseline your posture and find low-hanging fruit.
For more deep dives, I also publish on our blog: Pentest Testing Corp →.
The “bad” (what to avoid)
# config/packages/security.yaml
# ❌ Example of weak API auth: everything under /api is effectively public
security:
enable_authenticator_manager: true
firewalls:
dev:
pattern: ^/(?:_profiler|_wdt|bundles|css|images|js)/
security: false
api:
pattern: ^/api
stateless: true
# Missing any real authenticator here… 😬
access_control:
- { path: ^/api, roles: PUBLIC_ACCESS } # ← THIS makes your API unauthenticated
The “good” (secure defaults with JWT)
1) Install and generate keys
composer require lexik/jwt-authentication-bundle
php bin/console lexik:jwt:generate-keypair
2) Configure JWT
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%kernel.project_dir%/config/jwt/private.pem'
public_key: '%kernel.project_dir%/config/jwt/public.pem'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600 # 1 hour access tokens
token_extractors:
authorization_header:
enabled: true
prefix: Bearer
name: Authorization
3) Harden security.yaml
# config/packages/security.yaml
security:
enable_authenticator_manager: true
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto # chooses a modern hasher (e.g., Argon2id/BCrypt)
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(?:_profiler|_wdt|bundles|css|images|js)/
security: false
api:
pattern: ^/api
stateless: true
provider: app_user_provider
json_login:
check_path: /api/auth/login
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
# Optional: throttle login right on the firewall (Symfony 6.4+)
login_throttling:
max_attempts: 5
interval: '15 minutes'
access_control:
- { path: ^/api/auth/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
4) Add login route
# config/routes.yaml
api_login_check:
path: /api/auth/login
5) Use refresh tokens (rotation!)
composer require gesdinet/jwt-refresh-token-bundle
# config/packages/gesdinet_jwt_refresh_token.yaml
gesdinet_jwt_refresh_token:
ttl: 2592000 # 30 days for refresh tokens
ttl_update: true # rotate on each use
firewall: api
# config/routes.yaml
gesdinet_jwt_refresh_token:
path: /api/auth/token/refresh
// Example: calling the refresh endpoint
// POST /api/auth/token/refresh { "refresh_token": "..." }
Optional: API keys with scopes (service-to-service)
Some systems need machine-to-machine access where users aren’t involved. Use a scoped API key with expiry + rotation.
// src/Security/ApiKeyAuthenticator.php
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
use Symfony\Component\HttpFoundation\JsonResponse;
final class ApiKeyAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
return 0 === strpos($request->getPathInfo(), '/api/')
&& $request->headers->has('X-API-Key');
}
public function authenticate(Request $request): Passport
{
$apiKey = $request->headers->get('X-API-Key');
return new Passport(
new UserBadge($apiKey, function (string $key) {
// Look up ApiToken by token string, ensure not expired and scopes are valid
// return a User object (owner/service account)
return $this->tokenRepository->findActiveUserByToken($key);
}),
new CustomCredentials(function ($credentials, $user) use ($apiKey) {
// Optionally verify hash of the API key; never store raw keys
return $this->tokenVerifier->isValid($apiKey, $user);
}, $apiKey),
[new RememberMeBadge()] // has no effect in stateless, shown for completeness
);
}
public function onAuthenticationSuccess(Request $request, $token, string $firewallName)
{
return null; // allow request to continue
}
public function onAuthenticationFailure(Request $request, \Throwable $e)
{
return new JsonResponse(['message' => 'Invalid API Key'], 401);
}
}
Wire it up:
# config/packages/security.yaml (excerpt)
security:
firewalls:
api:
pattern: ^/api
stateless: true
custom_authenticators:
- App\Security\ApiKeyAuthenticator
Tips
• Store only a hash of the API key; show the raw value once.
• Attach scopes (e.g.,orders:read
) and expiration.
• Rotate keys automatically and log their usage.
Rate limiting & brute-force protection
Enable the RateLimiter component for more control (beyond login throttling).
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
api_ip:
policy: 'sliding_window'
limit: 100 # 100 requests
interval: '1 minute'
login:
policy: 'token_bucket'
limit: 5
rate: { interval: '1 minute', amount: 5 }
Use in a controller:
// src/Controller/AuthController.php
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\HttpFoundation\JsonResponse;
public function login(Request $request, RateLimiterFactory $login)
{
$limit = $login->create($request->getClientIp())->consume(1);
if (!$limit->isAccepted()) {
return new JsonResponse(['message' => 'Too many attempts'], 429);
}
// ...perform JSON login
}
Apply to all API routes via middleware:
// src/EventSubscriber/ApiRateLimitSubscriber.php
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactory;
final class ApiRateLimitSubscriber implements EventSubscriberInterface
{
public function __construct(private RateLimiterFactory $api_ip) {}
public function onKernelRequest(RequestEvent $event): void
{
$req = $event->getRequest();
if (!str_starts_with($req->getPathInfo(), '/api/')) return;
$limit = $this->api_ip->create($req->getClientIp())->consume(1);
if (!$limit->isAccepted()) {
$event->setResponse(new JsonResponse(['message' => 'Too many requests'], 429));
}
}
public static function getSubscribedEvents(): array
{
return ['kernel.request' => 'onKernelRequest'];
}
}
CORS and CSRF (don’t mix them up)
- APIs are typically stateless; disable CSRF for JSON endpoints.
- Configure CORS correctly, preferably with a whitelist.
composer require nelmio/cors-bundle
# config/packages/nelmio_cors.yaml
nelmio_cors:
defaults:
allow_credentials: false
allow_origin: ['https://your-frontend.example']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
allow_methods: ['GET','POST','PUT','DELETE','OPTIONS']
max_age: 3600
paths:
'^/api/':
allow_origin: ['https://your-frontend.example']
Short-lived tokens + refresh rotation (secure pattern)
# config/packages/lexik_jwt_authentication.yaml (excerpt)
lexik_jwt_authentication:
token_ttl: 3600 # 1 hour access tokens
# config/packages/gesdinet_jwt_refresh_token.yaml (excerpt)
gesdinet_jwt_refresh_token:
ttl: 1209600 # 14 days refresh tokens
ttl_update: true # rotate on every use (prevents replay)
Testing your protection (curl examples)
# 1) Login
curl -sX POST https://api.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email": "admin@example.com", "password": "S3cret!Pass"}'
# Response: {"token":"<JWT>"}
# 2) Call a protected endpoint
curl -s https://api.example.com/api/orders \
-H 'Authorization: Bearer <JWT>'
# 3) Refresh token
curl -sX POST https://api.example.com/api/auth/token/refresh \
-H 'Content-Type: application/json' \
-d '{"refresh_token":"<REFRESH_TOKEN>"}'
🔎 Our Free Tool screenshots
1) Website Vulnerability Scanner Screenshot
Screenshot of the free tools webpage where you can access security assessment tools.
This helps readers see how to kick off a scan in seconds.
2) Sample Vulnerability Report to check Website Vulnerability
Sample vulnerability assessment report generated with our free tool, providing insights into possible vulnerabilities.
This shows the kind of findings developers can expect after a scan.
Extra hardening checklist
- Enforce
IS_AUTHENTICATED_FULLY
(not “remember me”) for all write endpoints - Prefer Argon2id or automatic hasher migration for passwords
- Separate user JWTs from service API keys; never mix scopes
- Log failed auth attempts with user agent + IP (GDPR-aware)
- Return generic error messages; detail goes to logs, not responses
- Rotate secrets & keys regularly; store them in a secret manager
- Add security tests in CI (JWT misuse, missing auth, CORS regressions)
Quick code: protect everything under /api
(JWT or API key)
# config/packages/security.yaml (compact pattern)
security:
enable_authenticator_manager: true
firewalls:
api:
pattern: ^/api
stateless: true
# Choose ONE of the following authenticators:
# 1) JWT (end-user auth)
jwt: ~
# 2) Custom API key (comment the line above and uncomment below)
# custom_authenticators:
# - App\Security\ApiKeyAuthenticator
access_control:
- { path: ^/api/auth/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
Try it now (free)
Run a scan to catch weak authentication signals (exposed endpoints, headers, TLS issues) before attackers do:
👉 Run the free Website Security Scanner
And for deeper reading and walkthroughs:
- Blog: https://www.pentesttesting.com/blog/
- Newsletter: Subscribe on LinkedIn
Services you may need next
Managed IT Services (New)
From patching and endpoint management to identity & access hardening, we can own the “keep it running & secure” layer so your team ships features faster.
→ https://www.pentesttesting.com/managed-it-services/
AI Application Cybersecurity
If you’re shipping LLM features, secure model endpoints, API gateways, prompt injection defenses, and auth between services matter more than ever.
→ https://www.pentesttesting.com/ai-application-cybersecurity/
Offer Cybersecurity to Your Clients
Agencies & MSPs: white-label audits, DAST/SAST, and remediation playbooks you can deliver under your brand.
→ https://www.pentesttesting.com/offer-cybersecurity-service-to-your-client/
Final takeaway
Strong auth is a posture, not a plugin. With short-lived JWTs, scoped API keys, rotation, throttling, and sane defaults, your Symfony API gets both faster and safer. Save this post, ship the changes, and verify with a scan: free.pentesttesting.com.
Top comments (0)