DEV Community

Cover image for Weak API Authentication in Symfony: How to Fix It
Pentest Testing Corp
Pentest Testing Corp

Posted on

Weak API Authentication in Symfony: How to Fix It

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

Weak API Authentication in Symfony: How to Fix It

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
Enter fullscreen mode Exit fullscreen mode

The “good” (secure defaults with JWT)

1) Install and generate keys

composer require lexik/jwt-authentication-bundle
php bin/console lexik:jwt:generate-keypair
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

4) Add login route

# config/routes.yaml
api_login_check:
  path: /api/auth/login
Enter fullscreen mode Exit fullscreen mode

5) Use refresh tokens (rotation!)

composer require gesdinet/jwt-refresh-token-bundle
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# config/routes.yaml
gesdinet_jwt_refresh_token:
  path: /api/auth/token/refresh
Enter fullscreen mode Exit fullscreen mode
// Example: calling the refresh endpoint
// POST /api/auth/token/refresh  { "refresh_token": "..." }
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Wire it up:

# config/packages/security.yaml (excerpt)
security:
  firewalls:
    api:
      pattern: ^/api
      stateless: true
      custom_authenticators:
        - App\Security\ApiKeyAuthenticator
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'];
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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']
Enter fullscreen mode Exit fullscreen mode

Short-lived tokens + refresh rotation (secure pattern)

# config/packages/lexik_jwt_authentication.yaml (excerpt)
lexik_jwt_authentication:
  token_ttl: 3600        # 1 hour access tokens
Enter fullscreen mode Exit fullscreen mode
# 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)
Enter fullscreen mode Exit fullscreen mode

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>"}'
Enter fullscreen mode Exit fullscreen mode

🔎 Our Free Tool screenshots

1) Website Vulnerability Scanner Screenshot

Screenshot of the free tools webpage where you can access security assessment tools.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.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 }
Enter fullscreen mode Exit fullscreen mode

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:


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)