REST API Security Review: Auth, Rate Limiting, Input Validation, SSRF and Mass Assignment
AI generated
{ }
GET
REST API · Security · Symfony · OWASP
REST API Security Review
Auth, Rate Limiting, Input Validation, SSRF and Mass Assignment

Insecure REST APIs are not a rare occurrence. They do not arise from bad intentions but from missing security layers in a growing codebase. Authentication gaps, missing rate limiting, unvalidated input, SSRF vectors and mass assignment weaknesses are the most common findings in API security reviews, and they can be closed systematically with clear patterns.

18 min read Auth · Rate Limiting · Validation · SSRF · Mass Assignment Symfony 7 · PHP 8.4 · OWASP API Security Top 10

1. API security in context: what a review must deliver

A REST API security review is not a checklist you work through once, but a structured process that examines an API's security properties against known attack vectors. The OWASP API Security Top 10 lists the most common findings from real API security incidents, and the same classes have occupied the top spots for years: broken object-level authorization, broken authentication and excessive data exposure. These attack vectors are not abstract; they are concrete implementation gaps that can arise in any codebase that has grown over time.

The difference between an API security review and a penetration test lies in focus: a review analyzes source code, configuration and architecture, while a pentest confirms whether the discovered weaknesses can actually be exploited. The two complement each other. A good API security review uncovers structural weaknesses before they are exploited in production systems. The following sections cover the five most common finding categories, with concrete code examples for Symfony and PHP 8.4.

2. Implementing authentication and authorization correctly

Authentication and authorization form the first line of defense for every REST API, and at the same time the most common source of critical weaknesses. The OWASP finding "Broken Object Level Authorization" describes the case where an endpoint checks whether a user is logged in but not whether that user is allowed to access the specific object. An attacker simply changes the ID in the URL and gains access to someone else's resources. The fix is consistent authorization checking at the object level, not merely at the route level.

JWT tokens are widely used in API systems but are frequently validated incorrectly. Common mistakes: the signature is not checked, the alg header is determined by the token itself (algorithm confusion), or the token is not invalidated after logout. For Symfony APIs, LexikJWTAuthenticationBundle with short-lived access tokens (15 to 60 minutes) and a refresh token mechanism is recommended. API keys must be stored hashed in the database, never in plain text. That allows secure invalidation without database exposure.


<?php
// src/Security/ApiKeyAuthenticator.php
// Secure API key authentication with timing-safe comparison

declare(strict_types=1);

namespace App\Security;

use App\Repository\ApiKeyRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
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\SelfValidatingPassport;

final class ApiKeyAuthenticator extends AbstractAuthenticator
{
    public function __construct(
        private readonly ApiKeyRepository $apiKeyRepository,
    ) {}

    public function supports(Request $request): ?bool
    {
        return $request->headers->has('X-Api-Key');
    }

    public function authenticate(Request $request): Passport
    {
        $rawKey = $request->headers->get('X-Api-Key', '');

        // Hash first, then look up, never store raw keys
        $hashedKey = hash('sha256', $rawKey);

        $apiKey = $this->apiKeyRepository->findByHashedKey($hashedKey);

        if ($apiKey === null || !$apiKey->isActive()) {
            throw new AuthenticationException('Invalid or inactive API key.');
        }

        // Check key scopes against required route scopes
        return new SelfValidatingPassport(
            new UserBadge($apiKey->getOwnerIdentifier())
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null; // Continue to controller
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
    }
}

3. Rate limiting: defending against brute force, scraping and DDoS

Rate limiting protects a REST API against brute-force attacks on authentication endpoints, against automated scraping of public data, and against unintentional or malicious overload. An API without rate limiting is an open system for any attacker with a script. Symfony offers a flexible solution with the RateLimiter component, built on Redis or database backends, supporting several algorithms: token bucket for even load distribution, sliding window for precise time-based limits, and fixed window for simple requests-per-minute limits.

Rate limiting must be applied in a differentiated way: login endpoints need strict limits per IP and per user account, public read APIs need more generous limits with an IP-based sliding window, and write endpoints need user-specific limits. The response must follow RFC 6585: HTTP 429 with a Retry-After header and a meaningful body. Rate limit information in response headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) helps API consumers adjust their requests without running into limits.


<?php
// src/EventSubscriber/RateLimitSubscriber.php
// Global rate limiting via Symfony RateLimiter

declare(strict_types=1);

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\RateLimiter\RateLimiterFactory;

final class RateLimitSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly RateLimiterFactory $apiLimiter,
        private readonly RateLimiterFactory $loginLimiter,
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::REQUEST => ['onRequest', 20]];
    }

    public function onRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();

        // Stricter limits for authentication endpoints
        $factory = str_starts_with($request->getPathInfo(), '/api/auth')
            ? $this->loginLimiter
            : $this->apiLimiter;

        $limiter = $factory->create($request->getClientIp());
        $limit   = $limiter->consume(1);

        if (!$limit->isAccepted()) {
            $retryAfter = $limit->getRetryAfter()->getTimestamp() - time();
            $response   = new JsonResponse(
                ['error' => 'Too Many Requests', 'retry_after' => $retryAfter],
                Response::HTTP_TOO_MANY_REQUESTS
            );
            $response->headers->set('Retry-After', (string) $retryAfter);
            $response->headers->set('X-RateLimit-Limit', (string) $limit->getLimit());
            $response->headers->set('X-RateLimit-Remaining', '0');
            $event->setResponse($response);
        }
    }
}

4. Input validation: every input is hostile

The core principle of input validation in a REST API is radically simple: any input coming from outside is potentially malicious, regardless of whether it comes from an internal service, a trusted partner or a public client. Validation must happen in depth: at the entry point (request parsing), in the domain object (invariants), and at the database boundary (parameterized queries). A single validation layer is never enough.

Symfony Validator with DTOs as input objects is the recommended pattern. The request is first deserialized into a DTO, which alone already filters out incorrectly structured input. Then the validator runs against the DTO and checks type, format, length, value range and business rules. SQL injection is ruled out by Doctrine ORM and parameterized queries, not by manual filtering. NoSQL injection, command injection via shell commands and path traversal are separate attack classes that require their own countermeasures.


<?php
// src/Dto/CreateProductRequest.php
// Strictly typed DTO with comprehensive validation constraints

declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class CreateProductRequest
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(min: 2, max: 200)]
        #[Assert\Regex(pattern: '/^[\p{L}\p{N}\s\-_.,!]+$/u', message: 'Invalid characters in name.')]
        public readonly string $name,

        #[Assert\NotBlank]
        #[Assert\Positive]
        #[Assert\LessThan(value: 1_000_000)]
        public readonly float $price,

        #[Assert\NotBlank]
        #[Assert\Choice(choices: ['physical', 'digital', 'subscription'])]
        public readonly string $type,

        #[Assert\Valid]
        public readonly ?AddressDto $shippingAddress = null,

        // Explicitly listed allowed fields, no mass assignment possible
        #[Assert\Count(max: 10)]
        #[Assert\All([
            new Assert\Length(max: 50),
            new Assert\Regex(pattern: '/^[a-z0-9_]+$/'),
        ])]
        public readonly array $tags = [],
    ) {}
}

5. SSRF: preventing server-side request forgery

Server-Side Request Forgery (SSRF) is an attack vector that is especially dangerous in API systems because it abuses the server as a proxy for internal requests. An attacker supplies a URL as a parameter, for instance for a webhook, an avatar download or a link-preview service, and the server fetches that URL. If the URL points to http://169.254.169.254/ (the AWS metadata service), to internal services on the private network, or to localhost, the attacker can gain access to systems that are not reachable from outside.

SSRF prevention requires several layers: a URL allowlist for known external services, DNS resolution followed by IP validation against private IP ranges (RFC 1918, link-local, loopback), timeouts and maximum response sizes, and redirect limiting or prohibition. DNS rebinding attacks, where an external domain resolves to an internal IP after validation, require the resolved IP to be re-checked at the time of the actual request; a separate DNS resolution is not sufficient.


<?php
// src/Service/SafeUrlFetcher.php
// SSRF prevention: DNS resolution + private IP range check

declare(strict_types=1);

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;

final class SafeUrlFetcher
{
    // RFC 1918, Loopback, Link-Local, Multicast
    private const BLOCKED_RANGES = [
        '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16',
        '127.0.0.0/8', '169.254.0.0/16', '0.0.0.0/8',
        '::1/128', 'fc00::/7', 'fe80::/10',
    ];

    public function __construct(
        private readonly HttpClientInterface $httpClient,
    ) {}

    public function fetch(string $url): string
    {
        $parsed = parse_url($url);

        if (!in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) {
            throw new \InvalidArgumentException('Only http/https URLs are allowed.');
        }

        $host = $parsed['host'] ?? '';
        $ip   = gethostbyname($host);

        if ($ip === $host) {
            throw new \RuntimeException('DNS resolution failed.');
        }

        foreach (self::BLOCKED_RANGES as $range) {
            if ($this->ipInCidr($ip, $range)) {
                throw new \RuntimeException("URL resolves to blocked IP range: {$ip}");
            }
        }

        // Re-resolve at request time to prevent DNS rebinding
        $response = $this->httpClient->request('GET', $url, [
            'timeout'          => 5,
            'max_redirects'    => 0, // No redirects, prevents bypass via redirect
            'resolve'          => [$host => $ip], // Pin IP from validation
            'headers'          => ['User-Agent' => 'mironsoft-safe-fetcher/1.0'],
        ]);

        return $response->getContent();
    }

    private function ipInCidr(string $ip, string $cidr): bool
    {
        [$subnet, $prefix] = explode('/', $cidr);
        $ipLong     = ip2long($ip);
        $subnetLong = ip2long($subnet);

        if ($ipLong === false || $subnetLong === false) {
            return false;
        }

        $mask = ~((1 << (32 - (int) $prefix)) - 1);
        return ($ipLong & $mask) === ($subnetLong & $mask);
    }
}

6. Mass assignment: controlling automatic binding

Mass assignment is a weakness where an API endpoint maps incoming JSON fields directly onto an object or a database row without checking which fields are actually allowed to be set. A classic example: a user profile endpoint accepts {"name": "Alice", "role": "admin"} and sets the role field even though it does not appear in the update form. The attacker only needs to know what the internal model looks like, often through OpenAPI docs or by analyzing other endpoints.

The fix is consistent use of DTOs as the input layer. The DTO explicitly defines which fields are accepted. There is no automatic take-over of fields from the request. The Symfony Serializer with explicitly defined groups or readonly properties on the DTO prevents the deserializer from mapping unknown fields onto properties. The model object itself should not expose public setters for security-critical fields. For the update endpoint, use a separate UpdateDTO that contains only the allowed fields, not the same DTO used for reading.

Attack vector Risk Countermeasure Symfony tool
Broken Auth Critical Short-lived JWTs, signature validation, no alg:none LexikJWTBundle
Brute Force / DDoS High IP- and account-based rate limiting, HTTP 429 RateLimiter Component
Injection (SQL, Shell) Critical DTOs + Validator, parameterized queries, no eval Doctrine ORM + Validator
SSRF High IP validation after DNS resolution, redirect prohibition HttpClient + custom validator
Mass Assignment High Explicit DTOs per endpoint, no direct entity mapping Serializer + readonly DTOs

8. Summary

A systematic REST API security review covers the five most critical attack vectors: authentication gaps caused by incorrectly implemented JWT validation or plaintext keys, missing rate limiting that enables brute-force attacks, unvalidated input that opens the door to SQL injection and command injection, SSRF where the server is abused as a proxy for internal requests, and mass assignment through automatic binding without explicit field lists. Each of these vectors has a clear countermeasure that can be implemented with Symfony's built-in tools.

The most important principle: security is a property of the whole system, not of a single component. A perfectly validated input is useless if object-level authorization is missing. A solid rate limit does little good if JWT tokens are not signed. Security in depth means that every layer, transport, authentication, authorization, validation, database access, implements its own protective measures. Regular reviews and automated security tests in the CI pipeline ensure that new features do not introduce new vulnerabilities.

REST API Security Review: The Essentials at a Glance

Authentication

Short-lived JWTs, hashed API keys, check object-level authorization, not just route-level. Prevent algorithm confusion.

Rate Limiting

Differentiated by endpoint type: stricter for auth endpoints, sliding window for public APIs. HTTP 429 with a Retry-After header.

Input Validation

DTOs as the input layer with Symfony Validator. Multiple validation levels. No manual SQL filtering, use Doctrine ORM.

SSRF & Mass Assignment

Validate IP after DNS resolution, forbid redirects. Explicit readonly DTOs per endpoint prevent mass assignment entirely.

9. FAQ: REST API Security Review

1Difference between authentication and authorization in APIs?
Authentication establishes identity, authorization establishes permission. Main OWASP finding: correctly authenticated users access other people's objects because object-level authorization is missing.
2Preventing JWT algorithm confusion?
Explicitly set the algorithm during validation, never accept the alg header from the token itself. Always reject alg:none. Configure LexikJWTBundle with signature_algorithm.
3Which rate limiting algorithm fits REST APIs?
Token bucket for normal APIs with allowed bursts. Sliding window for auth endpoints. Fixed window is simpler but vulnerable to burst attacks right at the boundary.
4Is client-side validation enough?
No. Client-side validation is pure UX, attackers send requests directly to the API. Only server-side validation is security relevant.
5Where do I spot SSRF-vulnerable spots?
Anywhere the server calls a URL controlled by the user: webhooks, avatar uploads by URL, link previews, OAuth callbacks. Search for file_get_contents, curl_exec, HttpClient with user input as the URL.
6Does DNS resolution protect against DNS rebinding?
No, a single resolution before the request is not enough. The resolved IP must be pinned at request time. Symfony HttpClient offers the resolve option for exactly this purpose.
7What is mass assignment and how dangerous is it?
Request body is mapped onto an object without field filtering. Attackers send role:admin or is_verified:true. Explicit DTOs with readonly properties prevent it entirely.
8Object-level authorization in Symfony?
Symfony Security Voters: the voter receives the specific object and the user. In the controller, denyAccessUnlessGranted('EDIT', $product). Never just check whether logged in, always check the specific object.
9Security headers for pure JSON APIs?
Yes: X-Content-Type-Options, Strict-Transport-Security, X-Frame-Options and Cache-Control: no-store for sensitive endpoints. They cost nothing and help against certain attack vectors.
10Automated security tests for APIs?
OWASP ZAP in API scan mode with an OpenAPI spec. PHPStan with security extensions. Contract tests that check authorization rules. Fuzzing tests for validation logic.