April 8, 2026 10 min read

API Gateway Security for FinTech — Beyond OAuth and API Keys

API keys and OAuth tokens are table stakes. If that's all you're doing to secure your payment APIs, you're leaving the door half open. Here's the layered security approach I use after building merchant-facing APIs that handle real money.

The Problem With "Just Use OAuth"

I hear this all the time: "We use OAuth 2.0, so our API is secure." OAuth handles authentication and authorization well, but it doesn't protect against replay attacks, payload tampering, or man-in-the-middle attacks on the request body. For a blog API, OAuth is plenty. For an API that moves money, it's the starting line.

After building merchant-facing payment APIs at two different companies, I've settled on a five-layer security model. Each layer catches what the others miss.

Five Layers of Payment API Security
Layer 5: mTLS — Network Identity
Layer 4: Request Signing — Tamper Proof
Layer 3: Replay Protection — Freshness
Layer 2: Rate Limiting — Abuse Prevention
Layer 1: API Keys + OAuth — Identity

Layer 1: API Keys + OAuth (The Baseline)

Every merchant gets a pair of API keys: a publishable key for client-side use and a secret key for server-to-server calls. The publishable key can only create tokens; the secret key can charge cards. This separation is critical — if a publishable key leaks from a mobile app, the damage is limited.

On top of API keys, I use OAuth 2.0 with short-lived access tokens (15 minutes) and longer refresh tokens (30 days). The access token goes in the Authorization header; the API key goes in X-API-Key. Both must be present and valid.

// Required headers for every API request
POST /v1/payments HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
X-API-Key: sk_live_4eC39HqLyjWDarjtT1zdp7dc
X-Request-ID: req_8f3a2b1c-4d5e-6f7a-8b9c
X-Timestamp: 2026-04-08T10:30:00Z
X-Signature: sha256=a1b2c3d4e5f6...

Common mistake: Putting API keys in URL query parameters. They end up in server logs, browser history, and CDN caches. Always use headers.

Layer 2: Rate Limiting (Smarter Than You Think)

Basic rate limiting (100 requests per minute per API key) stops naive abuse. But payment APIs need smarter limits. I implement three tiers:

Tier Limit Window Purpose
Global 1000 req/min Sliding DDoS protection
Per-merchant 100 req/min Sliding Fair usage
Per-endpoint 10 req/min Fixed Sensitive ops (refunds, payouts)

The per-endpoint tier is the one most people miss. A merchant making 10 refund requests per minute is almost certainly a bug or an attack. Legitimate refund patterns are much slower. By rate-limiting sensitive endpoints separately, you catch credential stuffing and automated abuse that global limits miss.

// Go middleware: per-endpoint rate limiting
func RateLimitMiddleware(limiter *redis.RateLimiter) gin.HandlerFunc {
    return func(c *gin.Context) {
        merchantID := c.GetString("merchant_id")
        endpoint := c.FullPath() // e.g., "/v1/refunds"

        // Tier 1: Global
        if !limiter.Allow("global", 1000, time.Minute) {
            c.JSON(429, gin.H{"error": "rate_limit_exceeded"})
            c.Abort()
            return
        }

        // Tier 2: Per-merchant
        key := fmt.Sprintf("merchant:%s", merchantID)
        if !limiter.Allow(key, 100, time.Minute) {
            c.JSON(429, gin.H{"error": "merchant_rate_limit"})
            c.Abort()
            return
        }

        // Tier 3: Per-endpoint for sensitive operations
        if isSensitive(endpoint) {
            epKey := fmt.Sprintf("merchant:%s:%s", merchantID, endpoint)
            if !limiter.Allow(epKey, 10, time.Minute) {
                c.JSON(429, gin.H{"error": "endpoint_rate_limit"})
                c.Abort()
                return
            }
        }

        c.Next()
    }
}

Layer 3: Replay Protection

Without replay protection, an attacker who intercepts a valid payment request can resend it and charge the customer again. Two mechanisms work together to prevent this:

Why both? Timestamps alone don't prevent replays within the 5-minute window. Request IDs alone require storing every ID forever (or risk replays after TTL expiry). Together, they give you tight protection with bounded storage.

Layer 4: Request Signing (The Big One)

Request signing is what separates payment-grade API security from everything else. The idea: the client computes an HMAC signature over the request body, timestamp, and method, then sends it in a header. The server recomputes the signature and compares. If they don't match, the request was tampered with.

Request Signing Flow
Client Side
1. Concatenate: method + path + timestamp + body
2. HMAC-SHA256 with secret key
3. Send signature in X-Signature header
Server Side
1. Recompute HMAC from request data
2. Constant-time compare signatures
3. Reject if mismatch → 401 Unauthorized
// Go: server-side signature verification
func VerifySignature(r *http.Request, secretKey string) error {
    signature := r.Header.Get("X-Signature")
    timestamp := r.Header.Get("X-Timestamp")

    // Read and restore the body
    body, _ := io.ReadAll(r.Body)
    r.Body = io.NopCloser(bytes.NewBuffer(body))

    // Build the signing string
    signingString := fmt.Sprintf("%s\n%s\n%s\n%s",
        r.Method,
        r.URL.Path,
        timestamp,
        string(body),
    )

    // Compute expected HMAC
    mac := hmac.New(sha256.New, []byte(secretKey))
    mac.Write([]byte(signingString))
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

    // Constant-time comparison prevents timing attacks
    if !hmac.Equal([]byte(signature), []byte(expected)) {
        return fmt.Errorf("signature mismatch")
    }

    return nil
}

The critical detail: use hmac.Equal() for comparison, not ==. String equality comparison leaks timing information — an attacker can figure out how many bytes of the signature are correct by measuring response times. Constant-time comparison eliminates this side channel.

Layer 5: mTLS (Mutual TLS)

Regular TLS verifies the server's identity to the client. mTLS goes both ways — the server also verifies the client's certificate. This means even if someone steals an API key, they can't make requests without the corresponding client certificate.

I use mTLS for server-to-server integrations, especially with banks and payment processors. Each merchant gets a unique client certificate issued from our internal CA. Certificate rotation happens every 90 days, automated via a renewal endpoint.

Standard TLS
  • Server proves identity to client
  • Client is anonymous at TLS layer
  • API key alone authenticates client
  • Stolen key = full access
mTLS
  • Both sides prove identity
  • Client cert required at TLS layer
  • API key + cert = two-factor for APIs
  • Stolen key alone = useless

The downside of mTLS is operational complexity. Certificate distribution, rotation, and revocation all need automation. If a merchant's cert expires and they haven't rotated, their integration breaks. I mitigate this with 30-day advance warnings and a grace period where both old and new certs are accepted.

Payload Encryption for Sensitive Fields

Even with TLS, I encrypt sensitive fields (card numbers, bank account details) at the application layer using JWE (JSON Web Encryption). This provides defense-in-depth — if TLS is somehow compromised (misconfigured proxy, corporate MITM), the sensitive data is still encrypted.

// Request with field-level encryption
{
  "amount": 5000,
  "currency": "USD",
  "payment_method": {
    "type": "card",
    "encrypted_data": "eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00ifQ..."
  },
  "merchant_reference": "order_12345"
}

Only the encrypted_data field is encrypted — the rest of the payload stays readable for logging, debugging, and routing. This is a deliberate choice. Encrypting the entire payload makes debugging nearly impossible and provides minimal additional security over field-level encryption.

Security Headers and Response Hygiene

What you send back matters as much as what you accept. Every API response from our gateway includes these headers:

// Security response headers
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cache-Control: no-store, no-cache, must-revalidate
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Request-ID: req_8f3a2b1c  // Echo back for tracing

And equally important — what you don't send back. Never include in API responses:

Real incident: A competitor's API was returning PostgreSQL error messages in 500 responses. An attacker used the table names and column names from those errors to craft a SQL injection attack on a different endpoint. Sanitize everything.

Audit Logging — The Security Camera

Every API request gets logged with: merchant ID, endpoint, IP address, request ID, timestamp, response code, and latency. Sensitive fields are redacted before logging. These logs feed into an anomaly detection system that flags unusual patterns:

The logs are immutable (append-only to a separate datastore) and retained for 7 years to satisfy PCI DSS and financial audit requirements.

Putting It All Together

No single layer is bulletproof. API keys can be stolen. Signatures can be computed if the secret leaks. mTLS certs can be compromised. But stacking all five layers means an attacker needs to compromise multiple independent systems simultaneously. That's the point — defense in depth turns a single vulnerability from a breach into a speed bump.

Start with API keys and rate limiting (you can ship this in a day). Add request signing next (a week of work). Then layer on replay protection and mTLS as your merchant base grows. You don't need all five layers on day one, but you need a plan to get there.

References

Disclaimer: This article reflects the author's personal experience and opinions. Security recommendations are general guidance — always conduct a thorough security assessment for your specific use case. Product names, logos, and brands are property of their respective owners.