API Security for B2B SaaS: Beyond OAuth and JWT

#API security B2B SaaS
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Your SaaS application has OAuth 2.0 in place. JWTs carry user identity across services. The login flow is solid. Then your first enterprise prospect sends over their vendor security questionnaire—forty pages of controls across access management, logging, incident response, and data handling—and you realize that OAuth and JWT answered exactly two questions out of forty.

B2B API security is not a single technology. It is a set of controls that enterprise procurement teams, security auditors, and compliance frameworks expect to see in combination. Implementing JWT authentication is necessary but nowhere near sufficient. The teams that consistently clear enterprise security reviews are the ones who have thought about rate limiting, API key lifecycle management, tenant-scoped authorization, and comprehensive audit logging as first-class infrastructure concerns—not as afterthoughts bolted onto a working product.

This post covers the controls that actually matter to enterprise clients, with concrete implementation guidance for PHP/Symfony backends and Next.js API routes.

Rate Limiting: The Control Nobody Implements Until It Is Too Late

Rate limiting sits at an interesting intersection of security, reliability, and fairness. From a security perspective, it is your first line of defense against credential stuffing, brute-force attacks, and scraping. From a reliability perspective, it protects your infrastructure from a single misbehaving client degrading service for everyone. From a fairness perspective, it ensures API quotas are distributed as your pricing model intends.

Enterprise clients ask about rate limiting not because they expect to hit your limits, but because its presence signals operational maturity. An API without rate limiting is an API that a rogue integration or a misconfigured client can accidentally take offline—and that is a risk enterprise procurement teams are not willing to accept.

Effective rate limiting operates at multiple levels simultaneously. Application-level limits (requests per minute per API key) enforce fair use. Endpoint-level limits (authentication attempts per IP) prevent credential attacks. Burst limits (short-window spikes allowed before throttling) accommodate legitimate integration patterns without penalizing normal usage.

In a Symfony application, the RateLimiter component implements token bucket and sliding window algorithms with Redis-backed state:

// config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        api_global:
            policy: sliding_window
            limit: 1000
            interval: '1 minute'
        api_auth:
            policy: token_bucket
            limit: 10
            rate: { interval: '1 minute', amount: 10 }
// src/EventListener/RateLimitListener.php
class RateLimitListener
{
    public function __construct(
        private RateLimiterFactory $apiGlobalLimiter,
        private RateLimiterFactory $apiAuthLimiter,
    ) {}

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
        $apiKey = $request->headers->get('X-API-Key', $request->getClientIp());

        $limiter = $this->apiGlobalLimiter->create($apiKey);
        $limit = $limiter->consume(1);

        if (!$limit->isAccepted()) {
            throw new TooManyRequestsHttpException(
                $limit->getRetryAfter()->getTimestamp() - time()
            );
        }
    }
}

Return Retry-After headers in 429 responses. Enterprise integrations are built to respect them—clients that are not respecting them are worth knowing about.

API Key Management: Rotation, Scoping, and Revocation

Many SaaS products issue a single API key per account and consider the matter settled. Enterprise clients do not work this way. They expect API keys to be rotatable without downtime, scoped to specific permissions, and revocable immediately upon a security incident—without affecting other keys on the same account.

A robust API key model separates the key from the credentials it grants. Rather than treating the API key itself as a secret that directly encodes permissions, store the key as a reference to a permission record in your database:

// src/Entity/ApiKey.php
class ApiKey
{
    private string $id;          // UUID, stored in DB
    private string $keyHash;     // SHA-256 of the actual key — never store plaintext
    private string $keyPrefix;   // First 8 chars, shown in UI (e.g., "wt_live_a1b2c3d4")
    private array  $scopes;      // ["read:users", "write:webhooks", "admin:billing"]
    private ?\DateTimeImmutable $expiresAt;
    private ?\DateTimeImmutable $lastUsedAt;
    private ?\DateTimeImmutable $revokedAt;
    private string $tenantId;
    private string $createdByUserId;
}

Store only the hash of the actual key value. When a client presents a key, hash it on the server side and compare against stored hashes. This means a database breach does not expose working API credentials—the pattern mirrors how password storage works, applied to API keys.

Key rotation without downtime requires supporting multiple active keys per account simultaneously during the transition window. An enterprise client rotating credentials for a production integration needs the new key to work before they can safely disable the old one. Design your key management UI and API to accommodate overlap periods of days or weeks, not seconds.

Scopes deserve their own consideration. Fine-grained scopes let enterprise clients follow the principle of least privilege—the integration that reads order data should not have write access to user accounts. Document your scopes clearly and enforce them at the middleware level, not scattered across individual endpoint handlers.

IP Allowlisting: Simple, Auditable, Enterprise-Expected

IP allowlisting is one of the oldest network security controls, and it remains one of the most requested by enterprise procurement teams. It provides a defense-in-depth layer that is easy to understand, easy to audit, and meaningful even if an API key is compromised—a stolen key cannot be used from an unauthorized IP.

Implementation is straightforward. Store allowed IP ranges (supporting both individual addresses and CIDR notation for corporate VPNs) per account, and validate incoming request IPs at the middleware layer before authentication:

// src/Security/IpAllowlistChecker.php
class IpAllowlistChecker
{
    public function isAllowed(string $clientIp, string $tenantId): bool
    {
        $allowedRanges = $this->tenantRepository->getIpAllowlist($tenantId);

        // Empty allowlist means no restriction (opt-in feature)
        if (empty($allowedRanges)) {
            return true;
        }

        foreach ($allowedRanges as $range) {
            if ($this->ipInRange($clientIp, $range)) {
                return true;
            }
        }

        return false;
    }

    private function ipInRange(string $ip, string $range): bool
    {
        if (!str_contains($range, '/')) {
            return $ip === $range;
        }

        [$subnet, $prefix] = explode('/', $range);
        $mask = ~((1 << (32 - (int) $prefix)) - 1);

        return (ip2long($ip) & $mask) === (ip2long($subnet) & $mask);
    }
}

One operational detail: trust the correct IP header. Requests arriving through a reverse proxy carry the real client IP in X-Forwarded-For, not REMOTE_ADDR. Symfony's Request::setTrustedProxies() handles this correctly when configured with your proxy's IP range. Getting this wrong produces an allowlist that blocks legitimate clients while passing attackers who can spoof headers—verify it in a staging environment that mirrors your production proxy setup.

Audit Logging for API Calls: What Enterprise Clients Actually Review

Audit logging was covered in the context of application-level changes in our post on SaaS architecture mistakes at Series A. API-level audit logging has a different shape: it needs to capture every authenticated API call with enough context to reconstruct what happened, when, and from where—without logging request or response bodies that may contain PII or sensitive data.

Enterprise clients reviewing your security controls want to see that API activity is logged immutably, that logs are retained for a defined period (typically 12–24 months), and that they can access logs for their own tenant's activity. Some require log export in standard formats for ingestion into their SIEM.

The minimum viable API audit log record includes: timestamp, API key identifier (not the key itself—the ID from your key management system), tenant ID, HTTP method, endpoint path (normalized to remove path parameters containing user data), response status code, response latency, and client IP. Notably absent: request bodies, response bodies, and authorization headers.

In Symfony, a kernel response listener captures this after the request completes:

class ApiAuditListener
{
    public function onKernelResponse(ResponseEvent $event): void
    {
        $request  = $event->getRequest();
        $response = $event->getResponse();

        if (!$request->attributes->has('_api_key_id')) {
            return; // Not an authenticated API request
        }

        $this->auditLogger->info('api.call', [
            'api_key_id'  => $request->attributes->get('_api_key_id'),
            'tenant_id'   => $request->attributes->get('_tenant_id'),
            'method'      => $request->getMethod(),
            'path'        => $this->normalizePath($request->getPathInfo()),
            'status'      => $response->getStatusCode(),
            'latency_ms'  => (int) ((microtime(true) - $request->server->get('REQUEST_TIME_FLOAT')) * 1000),
            'client_ip'   => $request->getClientIp(),
            'user_agent'  => substr($request->headers->get('User-Agent', ''), 0, 200),
        ]);
    }

    private function normalizePath(string $path): string
    {
        // Replace UUIDs and numeric IDs with placeholders
        return preg_replace(
            ['/\/[0-9a-f]{8}-[0-9a-f-]{27}\//', '/\/\d+\//'],
            ['/{id}/', '/{id}/'],
            $path
        );
    }
}

Write audit logs to an append-only store. In practice, this means either a dedicated database table with no DELETE or UPDATE grants for the application user, or forwarding to a log aggregation system like OpenSearch or a managed SIEM. Logs that can be modified by the application are not audit logs—they are logs.

Tenant-Scoped Authorization in API Routes

For a B2B SaaS product with multiple tenants, the most dangerous class of API security bug is cross-tenant data access—where an authenticated request from tenant A can retrieve or modify data belonging to tenant B. This is distinct from authentication (proving who you are) and application-level authorization (what actions you can perform). Tenant isolation is a third layer that must be enforced independently.

The failure pattern is subtle. An API endpoint that accepts a resource ID in the URL path—GET /api/orders/{orderId}—may correctly validate that the caller is authenticated and has the read:orders scope. But if it queries the database by ID without verifying that the order belongs to the caller's tenant, a caller who guesses or enumerates order IDs can read anyone's data.

The fix is to always filter by tenant ID at the data access layer, not at the controller layer. In a Symfony application, this can be enforced through Doctrine's global filter mechanism (described in our multi-tenant architecture guide) or through a repository pattern that always includes the tenant constraint:

// src/Repository/OrderRepository.php
class OrderRepository extends ServiceEntityRepository
{
    public function findForTenant(string $orderId, string $tenantId): ?Order
    {
        return $this->findOneBy([
            'id'       => $orderId,
            'tenantId' => $tenantId,  // Always scoped — cannot be omitted
        ]);
    }
    // No findById() method — forces callers to provide tenantId
}

By making findById() unavailable and requiring callers to use findForTenant(), the repository makes the safe choice the only choice. An endpoint that forgets to pass the tenant ID fails at compile time or early in testing—not silently in production.

In a Next.js API route, the same principle applies. Middleware should resolve and attach the tenant context to the request before any route handler runs:

// middleware.ts — resolve tenant from API key, attach to request
export async function middleware(request: NextRequest) {
  const apiKey = request.headers.get('x-api-key');
  if (!apiKey) return new Response('Unauthorized', { status: 401 });

  const keyRecord = await resolveApiKey(apiKey); // hashes key, looks up record
  if (!keyRecord || keyRecord.revokedAt) {
    return new Response('Unauthorized', { status: 401 });
  }

  const headers = new Headers(request.headers);
  headers.set('x-tenant-id', keyRecord.tenantId);
  headers.set('x-api-key-id', keyRecord.id);
  headers.set('x-api-scopes', keyRecord.scopes.join(','));

  return NextResponse.next({ request: { headers } });
}

Route handlers read x-tenant-id from the processed request headers and pass it to every data access call. Because the tenant ID comes from the verified key record—not from a caller-supplied query parameter—tenants cannot scope their own requests to another tenant's data.

Putting It Together: The Security Control Matrix

Enterprise security questionnaires are easier to navigate when you can map your controls to the standard frameworks they reference. The controls above map directly to common audit categories:

Rate limiting addresses availability and denial-of-service resilience. API key rotation and revocation address credential lifecycle management, a core requirement in ISO 27001 and SOC 2 access control domains. IP allowlisting provides network-level access restriction that many financial services clients contractually require. Audit logging addresses the monitoring and logging requirements in virtually every compliance framework. Tenant isolation addresses data segregation and access control.

None of these controls is particularly complex to implement in isolation. The challenge is implementing them as coherent infrastructure—consistently applied, operationally monitorable, and demonstrably working—before the first enterprise deal is in your pipeline rather than after.

A code quality audit of a B2B SaaS API specifically checks whether these controls are consistently applied across all endpoints or whether gaps exist in corner cases and less-traveled code paths. A tech stack strategy review can help prioritize which controls to implement first given your current customer base and the compliance requirements of your target verticals.

Security Is a Posture, Not a Feature

OAuth and JWT are the entry ticket to the enterprise conversation. They demonstrate that you have thought about authentication. The controls covered in this post are what keep you in that conversation—and what ultimately determine whether enterprise procurement teams sign off on the deal or ask you to come back when the security controls are more mature.

The good news is that these controls are well-understood, implementable incrementally, and do not require rebuilding your application. The teams that navigate enterprise security reviews most smoothly are the ones who treated these controls as infrastructure from the beginning, rather than as features to retrofit under deadline pressure.

If you are preparing for your first enterprise security review or have inherited a codebase that needs a security posture assessment, Wolf-Tech offers a focused API security review as part of our code quality consulting practice. Contact us at hello@wolf-tech.io or visit wolf-tech.io to discuss what a review would cover for your specific stack.