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.
Table of Contents
- 1. API security in context: what a review must deliver
- 2. Implementing authentication and authorization correctly
- 3. Rate limiting: defending against brute force, scraping and DDoS
- 4. Input validation: every input is hostile
- 5. SSRF: preventing server-side request forgery
- 6. Mass assignment: controlling automatic binding
- 7. Security measures compared
- 8. Summary
- 9. FAQ
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.