Symfony API Security: Authentication, Rate Limiting, and Input Validation in One Production-Ready Setup

#symfony api security
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Most Symfony API security advice covers one layer at a time: a tutorial on JWT, a separate one on voters, another on rate limiting. In production those layers depend on each other, and the gaps between them are usually where the real vulnerabilities show up. This post assembles a complete Symfony API security setup from authentication through to the fields you expose in a response, using the pieces we reach for on client projects at Wolf-Tech.

Authentication: JWT for users, API keys for machines

For most Symfony APIs, JWT authentication through lexik/jwt-authentication-bundle is still the right default. It plugs into Symfony's security component with minimal boilerplate and works well for stateless APIs that serve a web or mobile frontend.

The part teams get wrong is token lifetime and rotation. A common setup issues an access token that expires in 15 minutes and a refresh token that lives for two weeks, stored separately and rotated on every use. When a refresh token is used, the API issues both a new access token and a new refresh token, and invalidates the old refresh token immediately. That last step matters: without it, a leaked refresh token stays valid until it naturally expires, even after the legitimate user has issued a newer one.

Symfony's stateless: true firewall configuration is what makes this work cleanly. No session cookie, no CSRF token to manage, and no server-side session storage to scale. The firewall configuration for a typical setup looks like:

security:
    firewalls:
        api:
            pattern: ^/api
            stateless: true
            jwt: ~

JWT is the right fit when a human is behind the request. Server-to-server integrations, webhooks consumers, and partner API access are a different problem. For those, a separate API key firewall with hashed keys stored in the database, checked against a dedicated authenticator, avoids forcing machine clients through a token refresh flow they don't need. Keep the two firewalls on different route patterns so a leaked API key can't be replayed against user-facing endpoints and vice versa.

If your API serves external partners, our custom software development work often starts exactly here: separating human and machine authentication before adding a new integration, rather than bolting API keys onto an existing JWT firewall after the fact.

Authorization: voters for anything more specific than a role

Role-based access control handles the easy cases. The harder case, and the one that actually causes incidents, is resource-level authorization: can this authenticated user edit this specific invoice, or only invoices belonging to their own organization. Symfony's security voters exist for exactly this.

A voter for invoice access checks the subject against the current user's organization, not just their role:

class InvoiceVoter extends Voter
{
    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        return $subject->getOrganization() === $user->getOrganization();
    }
}

Once you have more than one voter touching the same resource, the AccessDecisionManager strategy decides how their votes combine. The default affirmative strategy grants access if any voter approves, which is usually wrong for security-sensitive resources: it means a single overly permissive voter can override every other check. For anything handling billing, personal data, or account settings, switch to unanimous, which requires every applicable voter to agree.

security:
    access_decision_manager:
        strategy: unanimous

This is the layer that separates "the user is logged in" from "the user is allowed to do this specific thing," and it's the one most audits find missing entirely, with authorization logic scattered across controllers instead of centralized in voters where it can actually be tested.

Rate limiting: protecting the API from its own clients

Authentication and authorization answer who is calling. Rate limiting answers how often they're allowed to call. The symfony/rate-limiter component with a Redis backend handles this without the API server tracking state itself, which matters as soon as you run more than one instance behind a load balancer.

A sliding window limiter configured per endpoint looks like this:

framework:
    rate_limiter:
        api_write:
            policy: sliding_window
            limit: 60
            interval: '1 minute'
        api_read:
            policy: sliding_window
            limit: 300
            interval: '1 minute'

Splitting read and write limits matters because a client hammering a search endpoint shouldn't consume the same budget as one submitting orders. When a limit is hit, return a 429 with Retry-After and X-RateLimit-Remaining headers rather than a bare error. Well-behaved API clients read those headers and back off automatically, which cuts down on retry storms during traffic spikes far more effectively than a stricter limit would.

Rate limiting by IP alone breaks down behind shared corporate networks and mobile carrier NAT, where hundreds of legitimate users share one address. Keying the limiter on the authenticated user or API key, falling back to IP only for anonymous requests, gives a much more accurate picture of actual abuse.

Input validation: catching bad data before it reaches your domain

JWT and voters keep out people who shouldn't be there. Validation keeps out data that shouldn't be there, from someone who otherwise has every right to make the request.

Symfony's Validator component, applied through constraint attributes on your DTOs, handles the structural checks: required fields, string length, numeric ranges, valid enum values. When the API is built with API Platform, this validation runs automatically as part of deserialization, before your business logic ever sees the object. A request with a missing required field or a string that's too long gets rejected at the framework boundary with a 422 and a field-level error message, not three layers deep in a service class.

class CreateInvoiceRequest
{
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    public string $reference;

    #[Assert\Positive]
    public int $amountCents;

    #[Assert\Choice(choices: ['EUR', 'USD', 'GBP'])]
    public string $currency;
}

This covers structure, but not business rules that depend on state, like checking that an invoice amount doesn't exceed a client's credit limit. Those checks belong in a service layer that runs after basic validation passes, since they usually need a database lookup that a constraint attribute can't perform on its own.

A code review is often where these gaps surface first: a validation constraint that checks length but not format, a voter that was added for one controller and never applied to the API version of the same resource. If you want a second pair of eyes on an existing API before a security incident forces the issue, that's the kind of review we do as part of our code quality consulting.

Documenting the API without leaking fields you didn't mean to expose

NelmioApiDocBundle generates an OpenAPI specification directly from your route annotations and DTOs, which keeps the documentation from drifting out of sync with the actual API, a common problem with hand-maintained docs. That specification becomes especially useful for partner integrations, where an accurate, machine-readable contract cuts down on the back-and-forth of clarifying request and response shapes over email.

The part worth getting right before you publish that spec is serializer groups. Without them, Symfony's serializer exposes every public property on an entity, including ones you never meant to return over the API: internal notes, another user's email on a shared resource, a soft-deleted flag. Defining explicit groups per role means an admin endpoint and a public endpoint can return different shapes of the same entity, with the internal fields simply absent from the public group rather than filtered out after the fact.

#[Groups(['invoice:read', 'invoice:admin'])]
private string $reference;

#[Groups(['invoice:admin'])]
private ?string $internalNotes = null;

This is a case where the security model does the work that a code review would otherwise have to catch manually: a new field added to the entity is invisible to the API by default until someone deliberately adds it to a group, rather than exposed by default until someone remembers to hide it.

Putting it together

None of these four layers replaces the others. JWT and API keys establish identity. Voters decide what that identity is allowed to touch. Rate limiting protects the API from being overwhelmed, by attackers or by a misbehaving client. Validation and serializer groups control what data goes in and what comes back out. A gap in any one of them tends to get discovered by whoever finds it first, and it's rarely the API's own team.

If you're building a new Symfony API or hardening an existing one before an enterprise customer's security review, Wolf-Tech has done this setup enough times to know where the gaps usually hide. Reach out at hello@wolf-tech.io or visit wolf-tech.io to talk through your specific setup.