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)