API Threat Detection via Traffic Analysis: Detecting BOLA, Enumeration, and Mass Assignment in Access Logs

API Threat Detection via Traffic Analysis: Detecting BOLA, Enumeration, and Mass Assignment in Access Logs

The Problem

A WAF is good at what it was designed to do: match known-bad signatures, enforce rate limits per IP, block malformed HTTP. It fails at a different class of problem entirely. Broken Object Level Authorization (BOLA) attacks — the top-ranked API vulnerability in the OWASP API Security Top 10 — produce HTTP requests that are syntactically correct, authenticated, pass all WAF rules, and return HTTP 200. The only thing wrong is the semantics: the user fetching the resource does not own it.

Enumeration, mass assignment, and API credential stuffing share the same property: each individual request looks legitimate. The attack is in the distribution over time, the ratio of status codes per caller, or the presence of unexpected fields in the request body. Detecting these requires structured access logs that preserve caller identity, object identifiers, response codes, and — for mass assignment — request body field names. A WAF log does not contain any of this at the per-request level with per-user attribution.

BOLA in access logs. An attacker with a valid session token iterates through other users’ order IDs:

GET /api/orders/1001 HTTP/1.1 200 - user_id=123
GET /api/orders/1002 HTTP/1.1 200 - user_id=123
GET /api/orders/1003 HTTP/1.1 200 - user_id=123
GET /api/orders/1004 HTTP/1.1 200 - user_id=123

The WAF sees: authenticated GET requests, all returning 200, request rate 1 per second, no signature matches. The only anomaly visible in the log stream is that user_id=123 is accessing a growing set of sequentially distributed object IDs. Whether those IDs belong to that user is application-domain knowledge the WAF does not have — but the access pattern itself, high cardinality of distinct object IDs from a single user in a narrow time window, is detectable without any knowledge of ownership.

Enumeration in access logs. An attacker probes for valid user identifiers:

GET /api/users/alice@example.com HTTP/1.1 200 - user_id=attacker
GET /api/users/bob@example.com HTTP/1.1 404 - user_id=attacker
GET /api/users/carol@example.com HTTP/1.1 404 - user_id=attacker
GET /api/users/david@example.com HTTP/1.1 200 - user_id=attacker

The signal is that one identity generates a disproportionate ratio of 404 responses to 200 responses on a resource endpoint — specifically, many distinct URIs returning 404 within a short window. A user stumbling on a dead link generates one or two 404s. A scanner generates dozens to thousands, with high URI cardinality.

Mass assignment in access logs. The legitimately documented endpoint accepts name and email:

PATCH /api/users/123 HTTP/1.1 200 - user_id=123
Body: {"name": "Alice", "email": "alice@example.com", "role": "admin", "verified": true}

If the API framework automatically maps all request body fields to the model without an explicit allowlist, role and verified are written to the database even though the API schema does not document them as writable. Access logs do not capture request bodies by default. When they do — typically via a logging middleware layer rather than at the proxy — the unexpected fields are directly detectable. Without body logging, the only signal is a later anomaly: a user whose role column changed without a corresponding administrative action.

API credential stuffing in access logs. Sequential API key testing at the token endpoint:

POST /api/auth/token HTTP/1.1 401 - api_key=key_aaa
POST /api/auth/token HTTP/1.1 401 - api_key=key_aab
POST /api/auth/token HTTP/1.1 401 - api_key=key_aac
POST /api/auth/token HTTP/1.1 200 - api_key=key_aad

The pattern is a burst of 401 responses from the same source on the auth endpoint, followed by a 200. Detecting the 401 burst is straightforward. The critical correlation is between the burst and the subsequent success: the attacker is now authenticated with a working credential.

Threat Model

BOLA (Broken Object Level Authorization). An authenticated user accesses objects owned by other users. The attack surface is any resource endpoint that accepts an object ID in the path or query string without verifying the requesting user has permission to read that specific object. Detection signal: high cardinality of distinct object IDs accessed by a single user_id within a time window. False positives exist: admin users legitimately access many objects, bulk export features do the same. Threshold and principal exclusions are necessary.

Enumeration. An attacker probes for valid resource identifiers — user IDs, email addresses, account numbers — by iterating through values and observing which return 200 versus 404. The prerequisite is an endpoint that behaves differently for existing versus non-existing identifiers (the canonical username enumeration vulnerability). Detection signal: 404 rate per user_id or remote_addr on resource endpoints, with high URI cardinality. The cardinality constraint distinguishes enumeration from repeatedly hitting one dead URL.

Mass assignment. An attacker sends undocumented fields in a write request, relying on the API framework to map them to the data model. Effective against frameworks that use dynamic object hydration without an explicit allowlist: Django REST Framework without read_only_fields, FastAPI without explicit model_fields, Rails’ permit omissions. Detection signal: presence of unexpected field names in request bodies, caught at the application middleware layer and emitted as a structured log event.

API credential stuffing. Automated testing of credential pairs or API keys against the authentication endpoint. Different from password spray in that keys often follow predictable patterns (UUID v4, hex strings of fixed length) that enable programmatic generation. Detection signal: >N 401 responses from the same remote_addr on auth endpoints within a time window, correlated with subsequent 200.

What is not in scope here. Application-level authorization logic (which requires integration with your permission model), semantic anomaly detection using ML baselines, and WAF bypass techniques at the HTTP layer. Those are covered in related articles. The techniques here are purely signal extraction from structured access logs.

Hardening Configuration

1. Structured Access Log Format for API Threat Detection

Default nginx and Apache log formats capture IP, method, URI, status, and bytes. They do not capture the authenticated user identity. Every detection query in this article requires user_id to be present in the log record. Set this field in your auth middleware and pass it downstream as a request header. The log format reads the header, not the session.

# /etc/nginx/nginx.conf — structured JSON access log for API threat analysis
log_format api_security escape=json
  '{'
    '"timestamp":"$time_iso8601",'
    '"remote_addr":"$remote_addr",'
    '"method":"$request_method",'
    '"uri":"$request_uri",'
    '"status":$status,'
    '"bytes_sent":$bytes_sent,'
    '"request_time":$request_time,'
    '"user_id":"$http_x_user_id",'
    '"session_id":"$cookie_session",'
    '"request_id":"$http_x_request_id",'
    '"user_agent":"$http_user_agent",'
    '"referer":"$http_referer"'
  '}';

# Apply to the API virtual host only — not static asset servers
server {
    listen 443 ssl;
    server_name api.example.com;
    access_log /var/log/nginx/api_access.log api_security buffer=32k flush=5s;
}

The escape=json directive handles special characters in field values without breaking JSON structure. buffer=32k flush=5s reduces fsync pressure without losing more than five seconds of logs on crash. The $http_x_user_id header must be set by your authentication middleware on every authenticated request; unauthenticated requests will log an empty string, which is itself a signal (many requests on authenticated endpoints with no user ID header indicates auth bypass attempts or misconfigured clients).

For Envoy, the equivalent structured access log configuration:

# envoy.yaml — access_log section
access_log:
  - name: envoy.access_loggers.file
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
      path: /var/log/envoy/api_access.json
      log_format:
        json_format:
          timestamp: "%START_TIME%"
          method: "%REQ(:METHOD)%"
          uri: "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%"
          status: "%RESPONSE_CODE%"
          bytes_sent: "%BYTES_SENT%"
          request_time: "%DURATION%"
          user_id: "%REQ(X-USER-ID)%"
          remote_addr: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%"
          request_id: "%REQ(X-REQUEST-ID)%"
          user_agent: "%REQ(USER-AGENT)%"

2. BOLA Detection via Object ID Cardinality (LogQL)

The detection strategy: within a five-minute window, count how many distinct object IDs each user_id accessed on a resource endpoint. Legitimate users access their own objects — typically a small, consistent set. An attacker walking sequential IDs generates a cardinality spike.

LogQL does not natively support distinct-count aggregations over log streams — that’s a Prometheus/metric concern. The practical approach is a two-layer setup: a LogQL pipeline that extracts the object ID, feeds into a Prometheus counter via a Loki-derived metric rule, and an alert on the counter.

First, extract the object ID from the URI using a Loki pipeline rule (for Loki 2.9+ with metric extraction):

# LogQL: extract object_id from /api/orders/<id> pattern
# Used as the basis for a recording rule or dashboard panel

{job="nginx-api"}
  | json
  | uri =~ "/api/(orders|users|accounts)/[^/?]+"
  | regexp `(?P<resource_type>[^/]+)/(?P<object_id>[^/?]+)$` uri
  | line_format `{{.user_id}} {{.resource_type}} {{.object_id}}`

For the alert itself, use a Prometheus recording rule that counts requests per (user_id, object_id) pair, then a separate alert on the cardinality per user:

# prometheus/rules/api_bola.yml
groups:
  - name: api_bola_detection
    rules:
      # Recording rule: tracks distinct (user_id, object_id) access events
      # Feed this from your application's Prometheus instrumentation or OTel pipeline
      # api_object_access_total{user_id, resource_type, object_id}

      - alert: BolaSuspiciousObjectAccess
        expr: |
          count by (user_id, resource_type) (
            increase(api_object_access_total[5m]) > 0
          ) > 50
        for: 1m
        labels:
          severity: warning
          category: bola
        annotations:
          summary: "Possible BOLA: {{ $labels.user_id }} accessing many distinct {{ $labels.resource_type }} objects"
          description: >
            User {{ $labels.user_id }} accessed more than 50 distinct {{ $labels.resource_type }}
            objects in a 5-minute window. Legitimate use cases for this threshold should be
            excluded via the admin_users label. Investigate whether the accessed IDs belong
            to this user.

For Loki-only environments without a Prometheus metric pipeline, use Grafana’s LogQL metric queries for dashboards, and trigger on the count directly:

# Grafana alert: count of requests per user per 5-minute step
# Alert when any user exceeds 50 requests to resource endpoints

sum by (user_id) (
  count_over_time(
    {job="nginx-api"}
    | json
    | uri =~ "/api/(orders|users|accounts)/[^/?]+"
    [5m]
  )
) > 50

This is a necessary but not sufficient BOLA signal. It detects high-volume access but misses slow BOLA (ten objects per hour, over multiple days). For slow BOLA, extend the window to [1h] with a proportionally lower threshold, and store the daily per-user object access set in a Redis HyperLogLog to track cardinality without storing the full ID set.

3. Enumeration Detection via 404 Rate and URI Cardinality (Elasticsearch)

High 404 rate from a single user on resource endpoints with high URI cardinality is the enumeration fingerprint. The query aggregates 404s by user_id over a one-hour window, computes both count and URI cardinality, and flags users who exceed both thresholds.

from elasticsearch import Elasticsearch
from datetime import datetime, timezone

es = Elasticsearch(
    "https://elasticsearch.internal:9200",
    http_auth=("security_reader", "READ_ONLY_PASSWORD"),
    verify_certs=True,
)

def detect_enumeration(threshold_404s: int = 20, threshold_uris: int = 15) -> list[dict]:
    query = {
        "size": 0,
        "query": {
            "bool": {
                "filter": [
                    {"range": {"@timestamp": {"gte": "now-1h"}}},
                    {"term": {"status": 404}},
                    # Restrict to resource endpoints, not static assets
                    {"regexp": {"uri.keyword": "/api/(users|orders|accounts|products)/.*"}},
                ]
            }
        },
        "aggs": {
            "by_user": {
                "terms": {
                    "field": "user_id.keyword",
                    "size": 500,
                    # Exclude anonymous/unauthenticated (empty user_id)
                    "exclude": ["", "-"],
                },
                "aggs": {
                    "total_404s": {
                        "value_count": {"field": "_id"}
                    },
                    "unique_uris": {
                        "cardinality": {
                            "field": "uri.keyword",
                            # precision_threshold trades accuracy for memory
                            # 1000 gives ~0.5% error rate
                            "precision_threshold": 1000,
                        }
                    },
                    "sample_uris": {
                        # Capture three example URIs for investigation context
                        "top_hits": {
                            "size": 3,
                            "_source": ["uri", "remote_addr", "timestamp"],
                        }
                    },
                },
            }
        },
    }

    results = es.search(index="nginx-api-*", body=query, request_timeout=30)
    findings = []

    for bucket in results["aggregations"]["by_user"]["buckets"]:
        count_404 = bucket["total_404s"]["value"]
        unique_uris = bucket["unique_uris"]["value"]

        if count_404 >= threshold_404s and unique_uris >= threshold_uris:
            sample = [
                h["_source"].get("uri") 
                for h in bucket["sample_uris"]["hits"]["hits"]
            ]
            findings.append({
                "user_id": bucket["key"],
                "count_404": count_404,
                "unique_uris": unique_uris,
                "sample_uris": sample,
                "severity": "high" if count_404 > 100 else "medium",
                "detection_time": datetime.now(timezone.utc).isoformat(),
            })

    return findings


if __name__ == "__main__":
    import json
    for finding in detect_enumeration():
        print(json.dumps(finding, indent=2))

This script runs cleanly as a scheduled job (cron, Airflow, or a Kubernetes CronJob). Route its output to your alerting pipeline — PagerDuty, Slack, or directly into your SIEM as a synthetic detection event.

The dual threshold matters. A single 404 count threshold catches crawlers and misconfigured clients that repeatedly hit one missing URL. The URI cardinality threshold distinguishes that from systematic enumeration: a misconfigured client hits the same bad URL repeatedly (low cardinality, high count); an enumerator hits many different bad URLs (high cardinality, moderate-to-high count).

For unauthenticated enumeration (the attacker does not have a session), replace user_id.keyword with remote_addr.keyword in the aggregation. Unauthenticated enumeration on login endpoints (testing email addresses for account existence) is particularly common; add a separate query scoped to /api/auth/ URIs with a lower threshold (10 distinct URIs, 10 minutes).

4. Mass Assignment Detection via Request Body Field Analysis

Nginx and most reverse proxies do not log request bodies. Mass assignment detection requires a middleware layer in the application itself. The middleware inspects the request body before the framework processes it, compares the submitted fields against the declared schema, and emits a structured log event for any unexpected fields.

# fastapi_mass_assignment_middleware.py
# Attach to any FastAPI application via app.middleware("http")

import json
import logging
from typing import Callable

from fastapi import Request, Response
from fastapi.routing import APIRoute
from pydantic import BaseModel

security_logger = logging.getLogger("security.mass_assignment")
security_logger.setLevel(logging.WARNING)


# Schema registry: maps (method, path_pattern) to the expected field set
# Populate this from your route definitions or generate it from Pydantic models
SCHEMA_FIELD_REGISTRY: dict[tuple[str, str], set[str]] = {
    ("PATCH", "/api/users/{user_id}"): {"name", "email", "phone", "bio"},
    ("POST", "/api/orders"): {"product_id", "quantity", "shipping_address"},
    ("PUT", "/api/products/{product_id}"): {"name", "description", "price", "stock"},
}


def get_expected_fields(method: str, path: str) -> set[str] | None:
    """Match request path against schema registry patterns."""
    import re

    for (reg_method, pattern), fields in SCHEMA_FIELD_REGISTRY.items():
        if reg_method != method:
            continue
        # Convert FastAPI path template to regex
        regex = re.sub(r"\{[^}]+\}", r"[^/]+", pattern) + "$"
        if re.match(regex, path):
            return fields
    return None


async def mass_assignment_detection_middleware(request: Request, call_next: Callable) -> Response:
    """
    Inspect request bodies on write operations for unexpected fields.
    Logs unexpected fields without blocking the request — detection without disclosure.
    """
    write_methods = {"POST", "PUT", "PATCH"}

    if request.method in write_methods:
        content_type = request.headers.get("content-type", "")

        if "application/json" in content_type:
            body_bytes = await request.body()

            try:
                body = json.loads(body_bytes)
            except (json.JSONDecodeError, UnicodeDecodeError):
                body = {}

            expected_fields = get_expected_fields(request.method, request.url.path)

            if expected_fields is not None and isinstance(body, dict):
                submitted_fields = set(body.keys())
                unexpected = submitted_fields - expected_fields

                if unexpected:
                    security_logger.warning(
                        json.dumps({
                            "event": "mass_assignment_attempt",
                            "user_id": getattr(request.state, "user_id", None),
                            "remote_addr": request.client.host if request.client else None,
                            "method": request.method,
                            "path": request.url.path,
                            "expected_fields": sorted(expected_fields),
                            "submitted_fields": sorted(submitted_fields),
                            "unexpected_fields": sorted(unexpected),
                            "request_id": request.headers.get("x-request-id"),
                        })
                    )

            # Reconstruct request with body intact for downstream handlers
            # FastAPI requires this because body streams are consumed once
            async def receive():
                return {"type": "http.request", "body": body_bytes}

            request._receive = receive

    return await call_next(request)

Register the middleware in your FastAPI app:

from fastapi import FastAPI
app = FastAPI()
app.middleware("http")(mass_assignment_detection_middleware)

The middleware intentionally does not reject the request. Returning a 400 on unexpected fields tells the attacker exactly which fields are monitored — they will simply remove the unexpected fields and try again, now aware of the schema boundary. Logging silently and allowing Pydantic (or your framework’s serializer) to discard the unexpected fields is the correct operational posture: the legitimate request proceeds, the attempt is logged, and the attacker receives no signal that they were detected.

One important note on body re-reading: FastAPI’s Request.body() is a coroutine that reads the stream once. After reading it for inspection, you must replace request._receive with a closure that returns the cached bytes — otherwise the route handler gets an empty body. The middleware above handles this correctly.

5. API Credential Stuffing Detection (LogQL Alert Rules)

Two-phase detection: first alert on the 401 burst, then correlate with subsequent success.

# Phase 1: High 401 rate on auth endpoints from a single source
# Alert threshold: 100 failures in 10 minutes from the same remote_addr

sum by (remote_addr) (
  count_over_time(
    {job="nginx-api"}
    | json
    | status = "401"
    | uri =~ "/api/auth/.*"
    [10m]
  )
) > 100

Phase 2 correlation is harder in LogQL alone because it requires joining two separate event streams. Use Grafana Loki’s correlation in an alert or a Python job that checks whether any remote_addr that fired a Phase 1 alert subsequently appears in a Phase 2 success:

# Phase 2: Successful authentication from an address that recently had a 401 burst
# Run this query for each remote_addr flagged in Phase 1

{job="nginx-api"}
| json
| status = "200"
| uri =~ "/api/auth/token"
| remote_addr = "<FLAGGED_IP>"
| __error__ = ""

For automated correlation, run the Phase 2 query via the Loki HTTP API immediately after Phase 1 fires, using the flagged remote_addr:

import httpx
import urllib.parse
from datetime import datetime, timedelta, timezone

LOKI_URL = "http://loki.monitoring.svc:3100"

def check_stuffing_success(remote_addr: str, lookback_minutes: int = 30) -> bool:
    """
    Check whether a Phase 1 flagged IP subsequently authenticated successfully.
    Returns True if a successful auth is found — the stuffing attack succeeded.
    """
    now = datetime.now(timezone.utc)
    start = now - timedelta(minutes=lookback_minutes)

    query = (
        f'{{job="nginx-api"}} | json | status = "200" '
        f'| uri =~ "/api/auth/token" | remote_addr = "{remote_addr}"'
    )

    params = {
        "query": query,
        "start": str(int(start.timestamp() * 1e9)),
        "end": str(int(now.timestamp() * 1e9)),
        "limit": "1",
    }

    response = httpx.get(
        f"{LOKI_URL}/loki/api/v1/query_range",
        params=params,
        timeout=10,
    )
    response.raise_for_status()
    data = response.json()

    streams = data.get("data", {}).get("result", [])
    return any(len(s.get("values", [])) > 0 for s in streams)

6. Grafana Alert Rules for API Threats

# grafana/provisioning/alerting/api_threats.yml
apiVersion: 1

groups:
  - orgId: 1
    name: API Threat Detection
    folder: Security
    interval: 1m
    rules:
      - uid: bola_object_access
        title: "BOLA: Suspicious Object Access Volume"
        condition: C
        data:
          - refId: A
            datasourceUid: loki-datasource
            model:
              expr: |
                sum by (user_id) (
                  count_over_time(
                    {job="nginx-api"}
                    | json
                    | uri =~ "/api/(orders|users|accounts)/[^/?]+"
                    [5m]
                  )
                )
              queryType: range
          - refId: C
            datasourceUid: "-100"
            model:
              conditions:
                - evaluator:
                    params: [50]
                    type: gt
                  operator:
                    type: and
                  query:
                    params: [A]
                  reducer:
                    type: last
              type: classic_conditions
        noDataState: NoData
        execErrState: Error
        for: 1m
        labels:
          severity: warning
          category: bola
        annotations:
          summary: "BOLA candidate: {{ $labels.user_id }} accessed >50 resource objects in 5m"

      - uid: api_enumeration
        title: "Enumeration: High 404 Rate"
        condition: C
        data:
          - refId: A
            datasourceUid: loki-datasource
            model:
              expr: |
                sum by (remote_addr) (
                  count_over_time(
                    {job="nginx-api"}
                    | json
                    | status = "404"
                    | uri =~ "/api/.*"
                    [10m]
                  )
                )
              queryType: range
          - refId: C
            datasourceUid: "-100"
            model:
              conditions:
                - evaluator:
                    params: [30]
                    type: gt
                  operator:
                    type: and
                  query:
                    params: [A]
                  reducer:
                    type: last
              type: classic_conditions
        noDataState: NoData
        execErrState: Error
        for: 2m
        labels:
          severity: warning
          category: enumeration
        annotations:
          summary: "Enumeration from {{ $labels.remote_addr }}: >30 404s in 10m"

      - uid: api_credential_stuffing
        title: "Credential Stuffing: Auth Endpoint 401 Burst"
        condition: C
        data:
          - refId: A
            datasourceUid: loki-datasource
            model:
              expr: |
                sum by (remote_addr) (
                  count_over_time(
                    {job="nginx-api"}
                    | json
                    | status = "401"
                    | uri =~ "/api/auth/.*"
                    [10m]
                  )
                )
              queryType: range
          - refId: C
            datasourceUid: "-100"
            model:
              conditions:
                - evaluator:
                    params: [100]
                    type: gt
                  operator:
                    type: and
                  query:
                    params: [A]
                  reducer:
                    type: last
              type: classic_conditions
        noDataState: NoData
        execErrState: Error
        for: 0s
        labels:
          severity: high
          category: credential_stuffing
        annotations:
          summary: "Credential stuffing from {{ $labels.remote_addr }}: >100 auth failures in 10m"

Expected Behaviour

BOLA alert firing. A Grafana alert panel shows user_id=attacker_session_42 with a count of 87 over the trailing five-minute window, against a threshold of 50. The Loki log panel filtered by that user_id shows a sequence of GET requests to /api/orders/ with sequential integer IDs, all returning 200, all within a 4.5-minute window. The user’s normal behaviour baseline (visible in the same panel over the previous 24 hours) shows access to 2–4 order IDs per day — their own orders. The alert fires after the 51st distinct access; by the time the alert is reviewed, the attacker has accessed 87. Containment action: revoke the session token, not the account — the attacker has a valid credential and will re-authenticate if the account is locked without a password reset.

Mass assignment log entry. The structured log record emitted by the middleware looks like:

{
  "event": "mass_assignment_attempt",
  "user_id": "usr_7f3a9c",
  "remote_addr": "203.0.113.47",
  "method": "PATCH",
  "path": "/api/users/usr_7f3a9c",
  "expected_fields": ["bio", "email", "name", "phone"],
  "submitted_fields": ["admin", "bio", "email", "name", "role", "verified"],
  "unexpected_fields": ["admin", "role", "verified"],
  "request_id": "req_abc123def456"
}

The unexpected_fields list is the direct indicator: role and verified are privilege-related fields that should not be writable by users. A single occurrence warrants investigation; a pattern of attempts from the same user_id is a confirmed privilege escalation attempt. The fact that the request returned 200 (Pydantic discarded the unexpected fields) gives the attacker false confidence — they believe the assignment succeeded.

Enumeration Elasticsearch query result. Running detect_enumeration() against a live log index returns:

{
  "user_id": "usr_scan_9a2b",
  "count_404": 847,
  "unique_uris": 834,
  "sample_uris": [
    "/api/users/zach@example.com",
    "/api/users/yvonne@example.com",
    "/api/users/xavier@example.com"
  ],
  "severity": "high",
  "detection_time": "2026-05-09T14:23:00+00:00"
}

847 total 404 responses, 834 distinct URIs — near-perfect cardinality means nearly every probe hit a different non-existent address, which is the signature of a dictionary-based email enumeration attack. The sample URIs confirm the pattern: alphabetically ordered email addresses from a wordlist.

Trade-offs

Object ID cardinality detection vs. legitimate bulk operations. An admin dashboard that renders all pending orders for a support agent will trigger the BOLA alert if the threshold is set to 50 and the order queue has 51+ items. Exclusions are necessary: maintain a list of user_id prefixes or role labels for service accounts and admin users, and add them to a without clause in the alert expression. This requires knowing your admin account identifiers, which should be stable. The alternative — raising the threshold — trades false positive reduction against true positive detection latency. At a threshold of 500, you will not detect BOLA attacks accessing fewer than 500 objects per session.

Request body logging and PII. The mass assignment middleware reads and logs field names only — not field values — which avoids logging PII in the detection event. If you extend the middleware to log values for investigation context (for example, to see what role the attacker tried to assign), ensure the log pipeline applies redaction to sensitive field names: password, ssn, credit_card, api_key. Field name logging without value logging is the safe default.

404 rate anomaly and legitimate use. A developer running integration tests against a staging environment backed by the same log index will trigger the enumeration alert. A CI/CD pipeline making health-check requests against non-existent test resources will too. Separate log streams for staging versus production, or add an environment label to log records and filter it in the query. The {job="nginx-api"} selector should distinguish production log streams.

LogQL metric queries and Loki resource consumption. The cardinality-based BOLA query scans all log lines matching the URI pattern in the evaluation window. At high request volumes (thousands of requests per second), this query is expensive. Use Loki recording rules (Loki 2.9+ with ruler component enabled) to pre-compute the counts, and alert on the recorded metric rather than re-scanning log lines on every evaluation interval.

Failure Modes

No user ID in access logs. Every detection query in this article depends on user_id being present in the log record. If auth happens in an upstream service that does not propagate the user identity as a request header, the nginx log captures an empty string or a literal dash. BOLA is completely invisible: all requests return 200, no user identity, no cardinality signal. The prerequisite fix is to instrument your auth middleware to set X-User-ID on every authenticated request, and to configure your proxy to log it. This is a configuration change, not an instrumentation change — it requires coordinating the auth service team and the platform team.

Logging only aggregate status codes. An operations team that monitors the overall 404 rate at the service level — http_requests_total{status="404"} — will see normal aggregate rates even during active enumeration if the attacker’s volume is small relative to total traffic. The enumeration signal is per-caller, not aggregate. If your monitoring is entirely Prometheus-based without per-user attribution, you have no enumeration visibility. The Elasticsearch query approach requires per-request structured logs in an indexed store, not aggregated counters.

Threshold calibration for slow BOLA. An attacker who reads a five-minute window threshold and accesses 49 objects per five-minute period will evade the alert indefinitely. Slow BOLA is a real attack pattern — scraping an entire order database at sub-threshold rate over days. The countermeasure is a daily cumulative alert: if a single user accessed more than 200 distinct objects over any 24-hour period, that warrants investigation regardless of per-minute rate. Slow enumeration requires the same approach: a daily 404 count per user, not just a ten-minute burst detector.

Not correlating 401 bursts with successful authentication. A credential stuffing alert on the 401 burst tells you an attack is in progress. It does not tell you whether it succeeded. Without Phase 2 correlation, your incident response workflow treats all stuffing alerts as “attack attempted” rather than “attack succeeded, active session exists.” The difference in response urgency is significant: an active session from a stuffed credential requires immediate session revocation across all services; a failed stuffing attempt requires rate-limit tightening. Automate the correlation check so that every Phase 1 alert automatically runs Phase 2 within sixty seconds.

Mass assignment with non-JSON content types. The middleware shown above only inspects application/json bodies. APIs that accept multipart/form-data or application/x-www-form-urlencoded are not covered. If your API accepts form submissions on write endpoints — common in older REST APIs and some file upload handlers — extend the middleware to parse those content types as well. The field name extraction logic is the same; only the parser changes.