BOLA and BFLA in Kubernetes-Hosted APIs: Object-Level Authorisation Gaps in Multi-Tenant Deployments
The Problem
OWASP API Security Top 10 has placed Broken Object-Level Authorisation (BOLA, API1) and Broken Function-Level Authorisation (BFLA, API5) at the top of the list since 2019, and they remain there for a reason: they are not subtle implementation bugs. They are architectural mistakes that reproduce themselves across teams and codebases because they look like non-problems during development.
Kubernetes compounds both. Developers operating in a namespace-per-tenant or namespace-per-service architecture develop an intuition that isolation is already handled at the infrastructure layer. This intuition is wrong in a specific, exploitable way.
BOLA: The Enumeration Attack Inside Kubernetes
BOLA is mechanically simple. An endpoint accepts a resource identifier — an order ID, document ID, user profile ID, invoice number — and returns the resource corresponding to that identifier without verifying that the authenticated caller is authorised to access that specific object. Authentication is present. The token is validated. The user is who they say they are. The check that is missing is: does this authenticated user own, or have a legitimate relationship with, the resource they are requesting?
In Kubernetes, the deployment looks like this: a FastAPI or Express service runs inside a pod in namespace: tenant-a. A second tenant’s service runs in namespace: tenant-b. The platform team has configured NetworkPolicy to prevent cross-namespace traffic. The operations team points to this and says: “Tenants are isolated.”
They are isolated at the network layer for pod-to-pod traffic. They are not isolated at the application layer. An authenticated user of tenant-a’s application who knows (or guesses, or enumerates) the order IDs used by tenant-b can make valid API requests for those resources through the application’s own exposed endpoints. The application talks to a shared database, or a database that contains cross-tenant data keyed by a resource ID. Nothing in the Kubernetes namespace boundary intercepts an HTTP request from a legitimate user to their own application that happens to contain another tenant’s resource ID.
# BOLA-vulnerable endpoint deployed in Kubernetes, namespace-isolated, still broken
@app.get("/api/orders/{order_id}")
async def get_order(order_id: str, current_user: User = Depends(get_current_user)):
# Authentication check: user is authenticated. Present.
# Authorisation check: does this user own this order? Missing.
order = await db.get_order(order_id)
if not order:
raise HTTPException(404)
return order # Returns regardless of whether current_user.id == order.user_id
The attack: authenticate as user 123. Send GET /api/orders/1, /api/orders/2, through /api/orders/10000. Each request is authenticated. Each returns 200 with order data. After the loop, you have the entire order history of every customer. The fact that your pod is in namespace: tenant-a and the data belongs to customers of a separate tenant is irrelevant — the network policy was never in the request path for this attack.
Resource ID predictability determines how easy enumeration is. Sequential integers are the worst case — any attacker who has seen one order ID can enumerate all others. UUIDs are better but not a control: a BOLA vulnerability with UUID identifiers is still a BOLA vulnerability. An attacker who obtains even a handful of other users’ UUIDs (from a leaked log, a verbose error response, or a second API endpoint that discloses IDs) can still query those specific resources. Do not treat UUID adoption as a BOLA fix.
BFLA: Role Bypass Against Admin Endpoints
BFLA is the function-level analogue. The API exposes endpoints that perform privileged operations — deleting users, modifying billing, accessing audit logs, promoting accounts — and those endpoints are supposed to be restricted to administrators or privileged roles. The restriction is either absent, enforced only on the frontend, or inconsistently applied so that some admin paths are protected while others are not.
# BFLA-vulnerable admin endpoint
@app.delete("/api/admin/users/{user_id}")
async def delete_user(user_id: str, current_user: User = Depends(get_current_user)):
# Authenticated. Not authorised by role.
# Any authenticated user can delete any other user.
await db.delete_user(user_id)
return {"deleted": user_id}
The attack vector here is endpoint discovery. The OpenAPI specification for this service, if exposed at /docs or /openapi.json, lists every route. If the spec is not exposed publicly, an attacker can use path-fuzzing tools against the authenticated API surface. Routes containing /admin/, /internal/, /management/, or /v2/ (where v2 contains deprecated admin paths that were never removed) are common targets. The attacker finds the endpoint, calls it with their regular user token, and the operation succeeds because the role check was never written.
In Kubernetes this pattern is worsened by the ease with which internal services become semi-externally accessible. An application intended to be internal-only gets an Ingress resource added during a debugging session. A service mesh misconfiguration makes an internal admin port reachable from a different namespace. The developers assumed the endpoint was unreachable from the internet and therefore left the role check out. Then it becomes reachable.
Why Namespace Isolation Is Not the Fix
Kubernetes namespaces provide genuine isolation for:
- Kubernetes API objects (RBAC can prevent namespace-a from reading namespace-b’s Secrets or ConfigMaps)
- Network traffic when NetworkPolicy is configured
- Resource quota and LimitRange enforcement
- Pod scheduling constraints
Kubernetes namespaces provide no isolation for:
- HTTP request-level authorisation within an application
- Database row-level access control
- Which resource IDs a user is permitted to query
- Which application functions a user’s role allows them to invoke
The namespace contains the application. It does not inspect the application’s HTTP traffic and enforce ownership semantics. Every BOLA and BFLA check must be implemented in application code, policy enforcement infrastructure, or both.
Threat Model
An authenticated attacker operating inside a multi-tenant SaaS platform with namespace-per-tenant isolation has the following attack surface:
-
Resource enumeration via BOLA: The attacker authenticates as a valid tenant-A user, then iterates resource IDs via sequential or UUID-based guessing. Each request is authenticated and logs as a normal API call. The response body contains another tenant’s data. Without anomaly detection on request patterns, this is indistinguishable from normal application usage until a large number of requests is reviewed.
-
Admin function abuse via BFLA: The attacker discovers admin endpoints through the application’s OpenAPI specification (exposed at
/openapi.json), path fuzzing, or JavaScript source analysis of the frontend. The endpoints exist in the same application binary, with the same authentication middleware. Only the absent role check separates a normal user from admin function access. -
Cross-tenant resource access via parameter tampering: In multi-tenant APIs that accept
tenant_idororg_idin the request body or query string, an attacker changes that parameter to a competitor’s tenant ID. If the application uses the client-suppliedtenant_iddirectly in database queries rather than deriving it from the authenticated token claims, the attacker accesses a different tenant’s data without any BOLA enumeration. -
Insider threat via BFLA: An authenticated employee with a regular user account discovers that the admin password reset endpoint has no role check. They use it to elevate themselves or a co-conspirator’s account to admin. This never appears in privilege escalation logs because the RBAC check was never invoked.
-
Lateral movement after initial access: An attacker with compromised credentials for one user account uses BOLA to enumerate the data of high-value accounts (found by querying
/api/usersto get IDs), then uses that information for spear phishing or account takeover via password reset flows.
Hardening Configuration
1. Enforce Object Ownership in Application Code
The primary fix for BOLA belongs in the application. Every endpoint that accepts a resource identifier must verify ownership before returning data. This check is a single comparison, not a complex operation — the persistent failure is that it is omitted, not that it is difficult to write.
# Correct: ownership check before returning data
@app.get("/api/orders/{order_id}")
async def get_order(
order_id: str,
current_user: User = Depends(get_current_user)
):
order = await db.get_order(order_id)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
# AUTHORISATION: does this user own this order?
if order.user_id != current_user.id:
# Return 404, not 403. Returning 403 confirms to the attacker that
# the resource exists — useful information for targeted BOLA attacks.
# 404 leaks nothing: the resource may not exist, or may exist and
# be inaccessible. The attacker cannot distinguish the two cases.
raise HTTPException(status_code=404, detail="Order not found")
return order
# Correct: role check before admin operation
@app.delete("/api/admin/users/{user_id}")
async def delete_user(
user_id: str,
current_user: User = Depends(get_current_user)
):
# AUTHORISATION: does this user have the admin role?
if "admin" not in current_user.roles:
raise HTTPException(status_code=403, detail="Admin role required")
await db.delete_user(user_id)
return {"deleted": user_id}
# Correct: derive tenant from token claims, not request body
@app.get("/api/tenant/orders")
async def list_tenant_orders(
current_user: User = Depends(get_current_user)
):
# current_user.tenant_id comes from the verified JWT claim, not from
# a query parameter or request body that the client controls.
orders = await db.get_orders_for_tenant(current_user.tenant_id)
return orders
The third pattern — deriving tenant_id from the token rather than from the request — eliminates an entire class of cross-tenant BOLA that is not enumeration-based. Any field that determines which data is returned must come from a claim the server has verified, not from a value the client supplies.
2. PostgreSQL Row-Level Security as Defence in Depth
Application-layer ownership checks can fail: a new developer writes a new endpoint and forgets the check, a code review misses it, or a refactor moves logic and drops the check in the process. Row-level security at the database layer provides a defence-in-depth backstop that is enforced transparently for every query, regardless of what the application code does.
-- Enable RLS on the orders table.
-- Without a policy, no rows are accessible by default (deny-by-default).
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Policy: a user can only see rows where orders.user_id matches the
-- session-local variable app.current_user_id.
CREATE POLICY orders_owner_isolation ON orders
USING (user_id = current_setting('app.current_user_id')::uuid);
-- Multi-tenant equivalent: policy scoped to tenant
CREATE POLICY orders_tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Admin bypass: service accounts with the 'app_admin' role bypass RLS.
-- Use this for legitimate admin operations, not as a workaround for
-- forgetting to set the session variable.
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
GRANT SELECT ON orders TO app_user;
GRANT ALL ON orders TO app_admin;
ALTER TABLE orders NO FORCE ROW LEVEL SECURITY; -- app_admin bypasses policies
The application must set the session-local variable before every query for the non-admin connection:
import asyncpg
async def execute_with_user_context(
conn: asyncpg.Connection,
user_id: str,
query: str,
*args
):
"""
Execute a query with the user context set for RLS enforcement.
Uses a transaction to ensure SET LOCAL is scoped correctly and
does not leak between requests on a pooled connection.
"""
async with conn.transaction():
# SET LOCAL is scoped to the current transaction.
# SET (without LOCAL) persists across transactions on the same
# connection — dangerous with connection pooling.
await conn.execute(
"SET LOCAL app.current_user_id = $1", user_id
)
return await conn.fetch(query, *args)
# Transaction commits here; connection returns to pool with
# no persistent user context set.
The critical implementation detail is SET LOCAL rather than SET. A SET without LOCAL persists on the connection session. In a connection pool, that means user A’s context leaks to user B’s query if the connection is reused without resetting. SET LOCAL is scoped to the transaction: it is automatically cleared when the transaction commits or rolls back, which prevents cross-request context leakage in pooled connections. Forgetting this distinction is one of the most common failures in RLS implementations.
When RLS is active and the application BOLA check is bypassed — through a missing check on a new endpoint, a direct database query that skips the ownership validation, or a bug in the application logic — the database silently returns an empty result set rather than the wrong user’s data. The application receives zero rows. From an exploitation standpoint, the attacker sees a 404 or empty list rather than another user’s records.
3. OPA Policy for External Authorisation in the Service Mesh
Application-layer checks are necessary but can be inconsistently applied across a microservices fleet. Externalising authorisation to OPA via Envoy’s ext_authz filter enforces ownership checks outside the application binary — a policy that applies uniformly regardless of which service or developer wrote the endpoint.
# opa-api-authz.rego
# Deployed as an OPA policy bundle; loaded by OPA sidecar running
# alongside each API pod. Envoy queries OPA before forwarding each
# inbound request to the application.
package api.authz
import future.keywords.if
import future.keywords.in
default allow = false
# Rule 1: users accessing their own resources via /api/{resource_type}/{resource_id}
allow if {
input.parsed_path[1] == "api"
resource_type := input.parsed_path[2]
resource_id := input.parsed_path[3]
resource_type in {"orders", "invoices", "documents", "profiles"}
# JWT subject claim from the verified token (Envoy validates the JWT
# signature before ext_authz is called)
caller_sub := input.attributes.metadata.filter_metadata["envoy.filters.http.jwt_authn"].sub
# Ownership data is loaded from the OPA bundle, which is kept current
# by the bundle server (updated asynchronously from the database).
# For low-latency requirements, use the partial eval or topdown cache.
owner := data.resource_owners[resource_type][resource_id]
owner == caller_sub
}
# Rule 2: admin users accessing /api/admin/* endpoints
allow if {
input.parsed_path[1] == "api"
input.parsed_path[2] == "admin"
caller_roles := input.attributes.metadata.filter_metadata["envoy.filters.http.jwt_authn"].roles
"admin" in caller_roles
}
# Rule 3: service-to-service calls using mTLS peer identity,
# not user JWTs — allow specific internal services to access
# administrative endpoints
allow if {
input.parsed_path[2] == "internal"
peer_principal := input.attributes.source.principal
startswith(peer_principal, "spiffe://cluster.local/ns/")
endswith(peer_principal, "/sa/billing-service")
}
# Deny response: return 404 for object-level denials, 403 for function-level.
# The response body is returned to the client directly by Envoy.
denial_response := {
"status": {"code": 404},
"body": "{\"error\": \"Not found\"}"
} if {
input.parsed_path[2] in {"orders", "invoices", "documents", "profiles"}
not allow
}
denial_response := {
"status": {"code": 403},
"body": "{\"error\": \"Forbidden\"}"
} if {
input.parsed_path[2] == "admin"
not allow
}
The default allow = false is not optional. An OPA policy without an explicit default is a policy that allows every request that does not match a deny rule — the inverse of what you want. Every policy should start with default allow = false and explicitly enumerate permitted patterns.
# envoy-ext-authz.yaml: Istio EnvoyFilter to route inbound requests
# through OPA before the application receives them.
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: opa-ext-authz
namespace: production
spec:
workloadSelector:
labels:
app: orders-api
configPatches:
- applyTo: HTTP_FILTER
match:
context: SIDECAR_INBOUND
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
subFilter:
name: envoy.filters.http.router
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
grpc_service:
envoy_grpc:
cluster_name: outbound|9191||opa.production.svc.cluster.local
timeout: 100ms
# failure_mode_allow: false means if OPA is unreachable,
# every request is denied. This is the correct setting.
# failure_mode_allow: true means OPA being down makes all
# authorisation checks pass — an availability/security tradeoff
# that makes OPA unavailability an attack vector.
failure_mode_allow: false
include_peer_certificate: true
transport_api_version: V3
# opa-deployment.yaml: OPA deployed as a sidecar or dedicated service.
# Using dedicated deployment here for fleet-wide policy enforcement.
apiVersion: apps/v1
kind: Deployment
metadata:
name: opa
namespace: production
spec:
replicas: 3 # OPA must be highly available; it is now in the critical path
selector:
matchLabels:
app: opa
template:
metadata:
labels:
app: opa
spec:
containers:
- name: opa
image: openpolicyagent/opa:0.65.0-envoy
args:
- run
- --server
- --addr=:8181
- --diagnostic-addr=:8282
- --set=plugins.envoy_ext_authz_grpc.addr=:9191
- --set=plugins.envoy_ext_authz_grpc.enable-reflection=true
- --set=decision_logs.console=true
- --bundle=bundle
- --set=services.bundle.url=http://bundle-server.production.svc.cluster.local:8080
- --set=bundles.bundle.resource=bundles/api-authz.tar.gz
- --set=bundles.bundle.polling.min_delay_seconds=10
- --set=bundles.bundle.polling.max_delay_seconds=30
ports:
- containerPort: 9191 # gRPC ext_authz
- containerPort: 8181 # REST API (policy management)
- containerPort: 8282 # diagnostics
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8282
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /health?bundle=true
port: 8282
initialDelaySeconds: 5
The readinessProbe includes ?bundle=true, which causes OPA to report itself as not ready until the policy bundle has been loaded. This prevents a scenario where OPA pods restart, begin accepting connections before the bundle is loaded, and therefore enforce no policy — silently allowing all requests until the bundle arrives.
4. OpenAPI Spec-Based BFLA Detection as Middleware
Not every team will deploy OPA immediately. A lighter-weight BFLA detection pattern uses the application’s own OpenAPI specification as the source of truth for which endpoints require which roles, enforced via middleware that runs for every request:
# bfla_middleware.py
import yaml
from functools import lru_cache
from fastapi import Request
from fastapi.responses import JSONResponse
import re
@lru_cache(maxsize=1)
def load_openapi_spec(path: str) -> dict:
with open(path) as f:
return yaml.safe_load(f)
def extract_openapi_path(request_path: str, spec_paths: list[str]) -> str | None:
"""
Match the incoming request path against OpenAPI path templates.
/api/orders/abc123 matches /api/orders/{order_id}.
"""
for spec_path in spec_paths:
# Convert OpenAPI path template to regex
pattern = re.sub(r"\{[^}]+\}", r"[^/]+", spec_path)
pattern = f"^{pattern}$"
if re.match(pattern, request_path):
return spec_path
return None
def required_roles(method: str, path: str, spec_path: str | None) -> list[str]:
"""
Return the roles required for this method+path combination
as declared in the OpenAPI spec's x-required-roles extension.
Returns empty list if no role requirement is declared.
"""
if not spec_path:
return []
spec = load_openapi_spec("/app/openapi.yaml")
path_spec = spec.get("paths", {}).get(spec_path, {})
method_spec = path_spec.get(method.lower(), {})
return method_spec.get("x-required-roles", [])
async def bfla_enforcement_middleware(request: Request, call_next):
spec = load_openapi_spec("/app/openapi.yaml")
spec_path = extract_openapi_path(request.url.path, list(spec.get("paths", {}).keys()))
roles_needed = required_roles(request.method, request.url.path, spec_path)
if roles_needed:
# Retrieve user from request state (populated by auth middleware
# that runs earlier in the middleware stack)
user = getattr(request.state, "current_user", None)
if user is None:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_roles = set(getattr(user, "roles", []))
if not user_roles.intersection(set(roles_needed)):
return JSONResponse({"error": "Forbidden"}, status_code=403)
return await call_next(request)
The OpenAPI spec annotation that drives this:
# openapi.yaml (excerpt)
paths:
/api/admin/users/{user_id}:
delete:
summary: Delete a user account
x-required-roles:
- admin
operationId: deleteUser
parameters:
- name: user_id
in: path
required: true
schema:
type: string
format: uuid
/api/admin/audit-logs:
get:
summary: Retrieve audit logs
x-required-roles:
- admin
- security-reviewer
operationId: getAuditLogs
This approach makes the authorisation model explicit in the API contract, reviewable in pull requests, and automatically enforced — rather than relying on individual developers to remember to add role checks to each new endpoint.
5. Kyverno Policy: Require Authorisation Model Declaration
The platform team cannot audit every application’s BOLA and BFLA implementation. A Kyverno admission policy that requires all API deployments to declare their authorisation model creates a mandatory checkpoint in the deployment pipeline: teams must think about authorisation before the workload can reach production.
# kyverno-require-authz-model.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-api-authz-declaration
annotations:
policies.kyverno.io/title: Require API Authorisation Model Declaration
policies.kyverno.io/description: >
All API deployments in production must declare their authorisation model
via annotation. This ensures platform security review has assessed the
authorisation approach and prevents deployments that have not considered
BOLA and BFLA controls.
spec:
validationFailureAction: Enforce
background: true
rules:
- name: require-authz-model-annotation
match:
any:
- resources:
kinds: [Deployment]
namespaces: [production, staging]
selector:
matchLabels:
app.kubernetes.io/component: api
validate:
message: >
API deployments in production must declare their authorisation model
via the security.company.com/authz-model annotation. Valid values:
"ownership" (BOLA: per-object ownership checks enforced),
"rbac" (BFLA: role-based function access enforced),
"ownership+rbac" (both),
"public" (no auth required — reviewed and approved),
"service-internal" (no external access, mTLS peer auth only).
Open a security review ticket if you are unsure which to use.
pattern:
metadata:
annotations:
security.company.com/authz-model: "ownership|rbac|ownership+rbac|public|service-internal"
security.company.com/authz-reviewed-by: "?*"
# Example compliant Deployment annotation block
metadata:
name: orders-api
namespace: production
labels:
app.kubernetes.io/component: api
annotations:
security.company.com/authz-model: "ownership+rbac"
security.company.com/authz-reviewed-by: "security-team@company.com"
security.company.com/authz-review-ticket: "SEC-4421"
A deployment without these annotations fails admission. The team must obtain a security review before their workload reaches production — which is also when the BOLA and BFLA checks are most easily added, before the codebase is deployed and operational.
6. Automated BOLA Testing in CI
Ownership checks that are present during security review and absent six months later due to refactoring are indistinguishable from checks that were never written. Automated BOLA testing in the CI pipeline detects regressions.
# test_bola.py: pytest-based BOLA regression tests
# Runs in CI with two real test user accounts against a staging environment.
import pytest
import httpx
STAGING_API = "https://api.staging.company.com"
@pytest.fixture
def user_a_client():
"""Authenticated client for test user A (tenant A)."""
token = authenticate(email="test-user-a@example.com", password="...")
return httpx.Client(
base_url=STAGING_API,
headers={"Authorization": f"Bearer {token}"}
)
@pytest.fixture
def user_b_order_id(user_b_client):
"""Create an order as user B and return its ID."""
response = user_b_client.post("/api/orders", json={
"product_id": "prod-001",
"quantity": 1
})
return response.json()["order_id"]
def test_bola_user_a_cannot_access_user_b_order(user_a_client, user_b_order_id):
"""User A must not be able to retrieve user B's order."""
response = user_a_client.get(f"/api/orders/{user_b_order_id}")
# Must be 404 (resource not found for this user) — not 200 with user B's data,
# and not 403 (which would confirm the resource exists).
assert response.status_code == 404, (
f"BOLA: user A accessed user B's order {user_b_order_id}. "
f"Status: {response.status_code}, Body: {response.text}"
)
def test_bola_user_a_cannot_list_user_b_resources(user_a_client, user_b_client):
"""User A's list endpoints must only return user A's resources."""
# Create resources as both users
order_b = user_b_client.post("/api/orders", json={"product_id": "prod-001", "quantity": 1}).json()
# List as user A
response = user_a_client.get("/api/orders")
assert response.status_code == 200
order_ids = [o["order_id"] for o in response.json()["orders"]]
assert order_b["order_id"] not in order_ids, (
f"BOLA: user A's order list contains user B's order {order_b['order_id']}"
)
def test_bfla_regular_user_cannot_delete_users(user_a_client):
"""Regular user must not be able to call admin delete endpoint."""
response = user_a_client.delete("/api/admin/users/some-other-user-id")
assert response.status_code in (403, 404), (
f"BFLA: regular user called admin delete. Status: {response.status_code}"
)
def test_bfla_regular_user_cannot_access_audit_logs(user_a_client):
"""Regular user must not be able to access audit log endpoints."""
response = user_a_client.get("/api/admin/audit-logs")
assert response.status_code in (403, 404), (
f"BFLA: regular user accessed audit logs. Status: {response.status_code}"
)
Run these tests on every pull request that touches API handler code, and in a nightly run against the staging environment with production-equivalent data shapes. A regression — a developer removes the ownership check during a refactor — fails CI and is caught before deployment.
Expected Behaviour
With ownership checks in application code and PostgreSQL RLS active: a request for another user’s order returns 404, not 200. The attacker who runs an enumeration loop receives a stream of 404 responses across the entire ID space they query. OPA decision logs record each denial as a structured event: {"input": {"parsed_path": ["api", "orders", "12345"]}, "result": {"allow": false}}. These logs are queryable — a sudden spike in OPA denials from a single JWT subject is an enumeration attempt.
With OPA ext_authz configured with failure_mode_allow: false: if the OPA deployment becomes unavailable (pod crash, resource exhaustion, network partition), Envoy returns 503 to every inbound request. This is the correct tradeoff: the application becomes temporarily unavailable rather than silently bypassing authorisation. Alert on OPA availability with the same urgency as the application itself, because OPA availability is now part of the application’s security-critical path.
With Kyverno admission policy enforced: a kubectl apply of a Deployment in the production namespace without the required authorisation model annotations is rejected immediately by the admission webhook with the message declaring exactly what annotation is missing. The team cannot proceed without completing the annotation — which means they cannot proceed without having thought about their authorisation model.
With the CI BOLA tests active: a developer who accidentally removes the ownership check from the order retrieval endpoint sees a test failure: BOLA: user A accessed user B's order abc123. Status: 200. The failure message names the exact vulnerability class, the endpoint affected, and the HTTP status that should have been 404. The fix is obvious.
Trade-offs
OPA ext_authz latency: OPA adds 5–20ms to every inbound request on a warm cache, spiking to 50–100ms during policy bundle reloads. For API endpoints with sub-50ms latency requirements, this is significant. Mitigation: colocate OPA as a sidecar rather than a dedicated service to eliminate network hop latency; use OPA’s partial evaluation to pre-compute decisions for common patterns; cache decisions at the Envoy layer for identical input hashes with short TTLs (appropriate for read-heavy APIs with stable ownership relationships).
PostgreSQL RLS and connection poolers: Most connection poolers (PgBouncer in transaction mode, pgpool) do not propagate session-local variables correctly across transactions. SET LOCAL is scoped to a transaction, which means it works correctly when the application wraps every query in a transaction. Forgetting the transaction wrapper causes SET LOCAL to behave like SET, which persists on the connection and leaks to subsequent requests. Test this explicitly under connection pool conditions — write a test that reuses pooled connections and verifies that user context from request N does not appear in request N+1.
OpenAPI spec-based BFLA enforcement: This middleware is only as complete as the spec annotations. Endpoints without x-required-roles annotations are not checked — silently unprotected rather than explicitly protected. Treat unannotated endpoints as a gap, not as “no role required.” Add a CI lint rule that fails any OpenAPI spec where an /admin/ or /internal/ path is missing the x-required-roles annotation.
Kyverno admission policy and deployment velocity: Teams will attempt to use valid but content-free annotations (security.company.com/authz-model: "rbac" with no actual role checks implemented) to pass admission. The policy enforces declaration, not implementation. Pair it with the automated BOLA/BFLA tests in CI and periodic security review of the authz-review-ticket references — the annotation is the entry point for a security review process, not a substitute for one.
Failure Modes
Returning 403 for BOLA denials: A 403 response tells the attacker that the resource exists and they are not authorised to access it. This confirms the resource ID is valid, which is information. Return 404 for object-level authorisation failures. The attacker cannot distinguish between “this resource does not exist” and “this resource exists and you cannot access it.” A 403 is appropriate only for function-level denials (BFLA), where the existence of the endpoint is already known and denying its existence is not useful.
OPA policy with implicit or explicit default allow = true: A policy that allows by default and denies specific patterns will always have gaps — new endpoints, new path patterns, new resource types that the policy author did not anticipate are permitted by default. The deny list can never be complete. Write all authorisation policies with default allow = false and enumerate what is permitted. Patterns not in the allowlist are denied automatically.
Not testing BOLA with a second user account: Unit tests that test the ownership check with mock objects do not verify that the ownership check is actually called in the live application. Integration tests that authenticate as user A and attempt to access user A’s resources pass trivially. Only tests that authenticate as user A and attempt to access user B’s actual resources (created in the test setup) verify the ownership boundary. If your test suite does not have this pattern, your BOLA controls are untested.
Using SET instead of SET LOCAL in PostgreSQL RLS context: A connection pool reuses connections between requests. If SET app.current_user_id = 'user-a' is called without LOCAL, the value persists on the connection after the query completes. The next request that reuses that connection — possibly from a different user — runs queries with user A’s RLS context until a new SET overwrites it. This produces cross-user data leakage that is difficult to reproduce in tests (it requires connection reuse under load) and intermittent in production (it depends on pool connection assignment timing). Always use SET LOCAL inside an explicit transaction.
Treating predictable UUIDs as a BOLA mitigation: UUID v4 resource IDs are not enumerable by brute force, but BOLA does not require enumeration. An attacker who obtains a target user’s resource IDs — from a log exposure, a verbose API response, a customer support disclosure, or a second API endpoint that lists IDs — can immediately exploit a BOLA vulnerability with those specific IDs. UUID adoption removes the sequential enumeration attack. It does not remove BOLA from any endpoint that lacks an ownership check.