Broken Access Control (BAC) é, segundo os dados do OWASP, a falha de segurança web mais prevalente que existe. Ela ocupa o #1 do OWASP Top 10 (A01) há anos — e, na edição A01:2025, 100% das aplicações testadas apresentaram alguma forma de quebra de controle de acesso. O motivo é simples: não há nada exótico em “deixar alguém ver o que não deveria”. É a categoria que reúne todos os casos em que a aplicação autentica corretamente quem você é, mas falha em decidir o que você pode fazer. O servidor confirma sua sessão, vê seu pedido para acessar o pedido #1234, e entrega — sem nunca perguntar se aquele pedido é seu.
O IDOR (Insecure Direct Object Reference) é o representante mais célebre dessa família. É a falha que troca um id na URL e devolve os dados de outra pessoa. Importa porque é trivial de explorar (não precisa de payload, ferramenta exótica ou condição de corrida — basta um navegador e curiosidade), difícil de detectar com scanners automáticos (a requisição é “válida”: autenticada e bem-formada), e devastadora no impacto: vazamento em massa de dados, modificação de registros alheios e, no pior caso, virar administrador. Este artigo é sua referência de bolso para citar em relatório e o guia de correção para quem escreve o código.
A taxonomia: BAC, IDOR, BOLA, BFLA
Esses acrônimos confundem porque descrevem o mesmo problema em camadas diferentes. Vamos cortá-los:
- Broken Access Control (BAC) — o guarda-chuva. Qualquer falha na aplicação das regras de autorização. Tudo abaixo é um subtipo de BAC. É a categoria A01:2025 do OWASP Top 10 (que, nesta edição, absorveu também o SSRF).
- IDOR (Insecure Direct Object Reference) — a aplicação expõe uma referência direta a um objeto interno (um
idde banco, um nome de arquivo) e não verifica se o usuário da sessão tem direito àquele objeto específico. É a causa raiz mais comum de BAC no nível de objeto. - BOLA (Broken Object Level Authorization) — o #1 do OWASP API Security Top 10 (API1:2023). É exatamente a mesma ideia do IDOR, só que com o vocabulário do mundo de APIs. IDOR e BOLA descrevem a mesma falha — use o termo conforme o público (IDOR para web clássica, BOLA em contexto de API).
- BFLA (Broken Function Level Authorization) — não é sobre qual objeto, e sim sobre qual função (API5:2023 no Top 10 de APIs). Aqui o problema é acessar um endpoint ou operação que deveria ser restrito a um papel mais alto (ex.: um usuário comum chamando
POST /api/admin/users). É o que normalmente leva à escalada vertical.
Mantenha esta distinção na cabeça porque ela define o eixo da escalada:
- Escalada horizontal — você acessa dados de outro usuário do mesmo nível. Trocar
userId=1001poruserId=1002. Tipicamente um IDOR/BOLA. - Escalada vertical — você ganha privilégios mais altos: vira admin, acessa o painel de gestão, executa funções administrativas. Tipicamente um BFLA, ou um IDOR combinado com mass assignment (ex.: setar
"role": "admin"no próprio perfil).
A anatomia do ataque
A vulnerabilidade vive em uma única linha de raciocínio defeituosa: buscar o objeto pelo identificador que o cliente forneceu, e devolvê-lo sem checar a propriedade.
Imagine um endpoint de detalhe de pedido. A usuária Alice (sessão autenticada) abre seu pedido:
GET /api/orders/1042 HTTP/1.1
Host: loja.exemplo.com
Cookie: session=<token-válido-da-alice>
No servidor, o código vulnerável tem esta forma:
@app.get("/api/orders/<int:order_id>")
@login_required # autenticação: OK, sabemos que é a Alice
def get_order(order_id):
order = db.query(Order).filter(Order.id == order_id).first()
return jsonify(order.to_dict()) # <- entrega QUALQUER pedido pelo id
O @login_required garante que há uma sessão válida. Mas autenticação não é autorização. O código busca Order.id == 1042 e devolve, ponto. Em nenhum momento ele compara o owner_id do pedido com a identidade da sessão. Então Alice simplesmente faz:
GET /api/orders/1043 HTTP/1.1
Host: loja.exemplo.com
Cookie: session=<token-válido-da-alice>
E recebe o pedido do Bob — endereço de entrega, itens, valor, talvez os últimos 4 dígitos do cartão. Multiplique isso por um laço de for percorrendo 1 até 99999 e você tem um vazamento em massa. Esse é o IDOR de leitura.
A versão de escrita é pior. Se o mesmo padrão existe num PUT/PATCH/DELETE, o atacante não só lê — ele modifica ou apaga registros alheios:
PATCH /api/orders/1043 HTTP/1.1
Host: loja.exemplo.com
Cookie: session=<token-válido-da-alice>
Content-Type: application/json
{"shipping_address": "Rua do Atacante, 42"}
A causa raiz é sempre a mesma: a decisão de autorização foi omitida ou colocada no lugar errado. O servidor confiou que, se o cliente conhece o
id, ele tem direito ao objeto. A obscuridade do identificador foi tratada como controle de segurança — e obscuridade não é controle.
Variações e bypasses
O IDOR raramente aparece no formato de manual. Vale conhecer as variações reais que encontramos em campo.
IDs previsíveis (sequenciais)
O caso clássico: identificadores auto-incrementais (1, 2, 3…). Tornam a enumeração trivial — basta iterar. Se você vê inteiros pequenos e crescentes em URLs ou bodies, é o primeiro lugar para olhar.
UUIDs não são proteção (vazamento + enumeração)
Trocar id=1042 por um UUID v4 (f47ac10b-58cc-...) dificulta a adivinhação, mas não corrige o IDOR. Se a aplicação não checa propriedade, qualquer UUID válido que o atacante obtenha por outro caminho funciona. E UUIDs vazam o tempo todo: em respostas de listagem, logs, cabeçalhos Location, e-mails, parâmetros de outras telas, histórico do navegador. Vale notar ainda que UUID v1 embute timestamp + endereço MAC do nó, o que o torna parcialmente previsível — então nem todo formato de UUID oferece a mesma resistência à adivinhação (use v4, aleatório). De qualquer forma, o UUID muda o custo do ataque, não a sua existência.
Mass assignment (a ponte para escalada vertical)
A aplicação cria/atualiza um objeto fazendo bind automático do JSON recebido nas colunas do modelo. O atacante adiciona um campo que não deveria controlar:
PATCH /api/users/me HTTP/1.1
Content-Type: application/json
{"name": "Alice", "role": "admin", "account_id": 7}
Se o servidor faz user.update(**request.json) sem allowlist de campos, role e account_id são gravados. Um IDOR horizontal (escrever no próprio objeto) vira escalada vertical (virar admin) ou pulo de tenant.
Contexto multi-tenant
Em SaaS B2B, o objeto pertence a uma organização, não só a um usuário. A checagem precisa incluir objeto.tenant_id == sessão.tenant_id (além da propriedade do usuário, quando aplicável). Falhar aqui significa um cliente lendo dados de outro cliente — uma quebra de isolamento que costuma ser tratada como incidente crítico, inclusive contratual.
GraphQL: node IDs e o padrão node(id:)
GraphQL (no estilo Relay) concentra acesso a objetos por ID global — tipicamente o base64 de algo como Order:1043. O resolver node(id: ...) busca por qualquer tipo/ID. Se a autorização não está dentro de cada resolver de objeto, o IDOR se espalha por todo o grafo de uma vez. Decodificar o ID global (base64 -d) frequentemente revela o inteiro sequencial por baixo — ou seja, a “opacidade” do ID global é só codificação, não autorização.
IDOR via método HTTP alternativo
A leitura (GET /api/orders/1043) está protegida, mas o DELETE ou o PUT no mesmo recurso não. Desenvolvedores frequentemente aplicam a checagem em um verbo e esquecem dos outros. Sempre teste todos os métodos sobre o mesmo objeto.
IDOR via parâmetro escondido
O id não está na URL — está num campo secundário, num cabeçalho (X-Account-Id), num cookie não assinado, ou num parâmetro JSON aninhado que a UI nunca expõe. Ferramentas como o Param Miner (extensão do Burp) ajudam a descobrir parâmetros e cabeçalhos não documentados que controlam a referência ao objeto.
Como exploramos no pentest
A metodologia é direta e quase sempre dá resultado. O segredo é duas contas.
1. Provisione duas contas do mesmo nível — chame-as de vitima e atacante. Idealmente, crie também uma terceira de nível mais alto (admin) para mapear o que cada papel acessa.
2. Mapeie os identificadores. Navegando como vitima, catalogue toda referência a objeto: IDs em URLs, bodies, headers, respostas JSON. Anote os IDs dos recursos da vitima (pedidos, faturas, mensagens, arquivos).
3. Troque os IDs entre contas. Autenticado como atacante, repita as requisições da vitima usando a sessão do atacante mas os IDs da vítima. No Burp Suite, isso é “Send to Repeater”, trocar o cookie de sessão e o id, e observar:
- Veio
200com os dados da vítima? IDOR de leitura confirmado. - O
PATCH/DELETEretornou sucesso? IDOR de escrita — valide relendo como a vítima. - Veio
403/404consistente? Provavelmente protegido (mas teste outros verbos e parâmetros antes de descartar).
4. Automatize com Burp Autorize. Esta extensão é feita exatamente para isso. Você configura os cookies/headers da conta de baixo privilégio; ao navegar com a conta de alto privilégio, o Autorize reenvia cada requisição com as credenciais rebaixadas para a conta fraca e classifica:
- Bypassed! — a requisição funcionou sem o privilégio adequado (achado).
- Enforced! — foi corretamente bloqueada.
- Is enforced??? — ambíguo; o detector não conseguiu decidir e a resposta exige análise/configuração manual (você adiciona uma string/regex de fingerprint para o Autorize aprender o padrão de bloqueio).
Isso transforma a caça a BAC numa varredura quase passiva enquanto você usa a aplicação normalmente. Ainda assim, confirme manualmente os achados marcados como Bypassed! — falsos positivos acontecem quando a resposta de erro e a de sucesso têm tamanho/forma parecidos.
5. Enumere em escala com o Turbo Intruder ou o Intruder do Burp: itere o id sobre uma faixa e meça as respostas (status e tamanho do corpo). Picos de 200 com tamanhos não-triviais delatam objetos acessíveis. Para GraphQL, automatize a iteração do inteiro embutido no ID global.
6. Demonstre impacto, não só a falha. Um bom finding de IDOR não diz “troquei o ID”. Ele mostra quantos registros estão acessíveis, que dados sensíveis vazam, e se há escrita (modificação/exclusão). É a diferença entre “Média” e “Crítica” no relatório.
Use somente os dados das suas contas de teste para provar a leitura cruzada. Não percorra a base inteira de usuários reais — colete o mínimo necessário (dois ou três IDs vizinhos) para demonstrar o padrão e respeite o escopo combinado.
Resumo para o relatório
- Impacto: Um usuário autenticado acessa, modifica ou exclui dados de outros usuários/organizações trocando um identificador de objeto na requisição (escalada horizontal). Quando combinado com mass assignment ou endpoints administrativos desprotegidos, leva a escalada vertical (comprometimento de conta administrativa) e vazamento em massa de dados pessoais.
- Severidade: Alta a Crítica. CVSS v3.1 de 6.5 para leitura (
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N), subindo a 8.1 quando há escrita/exclusão de dados alheios (...C:H/I:H/A:N) ou cruzamento de tenant. Escalada vertical para admin chega a Crítica (9.x). Pontue sempre conforme o caso concreto.- Pré-condições: Uma conta válida de baixo privilégio e conhecimento (ou enumerabilidade) de identificadores de objetos de outros usuários.
- Evidência sugerida: Par de requisições/respostas mostrando a conta A acessando objeto da conta B (com os cookies/sessão de A destacados e o
idde B); IDs envolvidos; captura do dado sensível retornado; para escrita, prova de releitura confirmando a alteração; saída do Burp Autorize marcando Bypassed!.
Como mitigar
A correção de BAC não é um patch pontual — é uma postura arquitetural: autorização centralizada, negada por padrão, e atrelada ao objeto.
1. Consulte com escopo de propriedade (a correção mais robusta)
A melhor defesa contra IDOR é nunca conseguir buscar um objeto que não é seu. Em vez de “buscar por id e depois checar”, filtre pela identidade da sessão na própria query:
# ERRADO: busca por id, confia que o id implica direito
order = db.query(Order).filter(Order.id == order_id).first()
# CERTO: o objeto só existe na query se pertencer ao usuário da sessão
order = (db.query(Order)
.filter(Order.id == order_id,
Order.owner_id == g.current_user.id) # escopo!
.first())
if order is None:
abort(404) # 404 também para "não autorizado" — não confirme a existência do objeto
Com WHERE owner_id = :session_user, um id de outra pessoa simplesmente não retorna nada. A autorização vira uma consequência da consulta, não um passo separado que pode ser esquecido. Em multi-tenant, adicione também tenant_id == g.current_user.tenant_id.
2. Autorização centralizada, deny-by-default
Não espalhe if user.is_admin pelo código. Centralize a decisão numa camada/política que todo endpoint atravessa, e faça o padrão ser negar:
def authorize(user, action, resource):
rule = POLICIES.get((action, type(resource).__name__))
if rule is None:
raise Forbidden() # deny-by-default: sem regra = negado
if not rule(user, resource):
raise Forbidden()
@app.patch("/api/orders/<int:order_id>")
@login_required
def update_order(order_id):
order = get_owned_or_404(Order, order_id, g.current_user) # escopo (passo 1)
authorize(g.current_user, "order:update", order) # política (passo 2)
...
Defesa em profundidade: a query com escopo já barra o horizontal; a política explícita cobre regras finas e o vertical.
3. RBAC/ABAC para funções (contra BFLA)
Para escalada vertical, proteja funções por papel. Aplique a checagem de papel no servidor, no roteamento, antes de chegar ao handler — nunca confie em a UI esconder o botão:
@app.post("/api/admin/users")
@require_role("admin") # RBAC: BFLA bloqueado na porta
def create_user(): ...
Para regras dependentes de contexto (horário, departamento, status do recurso), use ABAC — atributos do usuário e do objeto na decisão.
4. Allowlist de campos contra mass assignment
Nunca faça bind cego do body no modelo. Aceite apenas os campos que o usuário pode controlar:
ALLOWED = {"name", "email", "phone"} # 'role' e 'tenant_id' fora
data = {k: v for k, v in request.json.items() if k in ALLOWED}
user.update(**data)
5. Referências indiretas quando fizer sentido
Em vez de expor o id global do banco, exponha um mapa indireto por sessão (ex.: índice 0,1,2... que o servidor traduz para os IDs reais daquele usuário). É uma camada extra, não um substituto da checagem de propriedade — mas reduz a superfície de enumeração. Jamais trate UUID/hash como “autorização por obscuridade”.
6. Testes automatizados de autorização
Transforme a metodologia de pentest em teste de regressão: para cada endpoint sensível, um teste que verifica que a conta A recebe 404/403 ao acessar objeto da conta B, em todos os verbos. Rode no CI. BAC é a categoria que mais se beneficia de testes negativos automatizados, porque o scanner não pega.
Checklist de mitigação
- Toda consulta de objeto é escopada pela identidade da sessão (
WHERE owner_id/tenant_id = :session). - Autorização centralizada numa camada única, com política deny-by-default.
- Checagem de propriedade por objeto em todos os verbos (
GET,PUT,PATCH,DELETE) — não só na leitura. - RBAC/ABAC aplicado no servidor para funções administrativas (contra BFLA), antes do handler.
- Allowlist de campos em create/update (contra mass assignment);
role/tenant_id/is_adminnunca vêm do cliente. - Resolvers de GraphQL autorizam por objeto individualmente, incluindo
node(id:). - Resposta consistente (
404) para objeto inexistente ou não autorizado — não vaze existência via403vs404. - Identificadores não previsíveis (UUID v4 aleatório) como camada extra — nunca como controle único.
- Testes automatizados de autorização (conta A x objeto de B) rodando no CI.
IDOR e Broken Access Control são, no fundo, uma pergunta que o servidor esqueceu de fazer: “este objeto é mesmo seu?”. Autenticação confirma quem bate à porta; autorização decide quais portas se abrem. Quando o código busca pelo id que o cliente entregou e devolve sem checar a propriedade, ele delegou a segurança à boa-fé do usuário — e a boa-fé não é um modelo de ameaça. Escope cada query à sessão, negue por padrão, teste o acesso cruzado, e o IDOR deixa de ser uma troca de número para virar um 404 silencioso.