Suas ideias em realidade digital!
>
SQL Injection é uma das falhas de segurança mais conhecidas em aplicações web. Ela acontece quando dados enviados pelo usuário são colocados diretamente dentro de uma consulta SQL sem tratamento adequado. Em PHP, esse problema costuma aparecer em sistemas de login, formulários de busca, cadastros, filtros, APIs e painéis administrativos.
A boa notícia é que evitar SQL Injection em PHP não é complicado quando usamos PDO com prepared statements. Com essa abordagem, os valores enviados pelo usuário são tratados como parâmetros, e não como parte direta do comando SQL.
Neste artigo, você vai entender o que é SQL Injection, ver exemplos de código vulnerável e aprender como corrigir usando PDO, parâmetros nomeados, validação de dados e boas práticas para proteger sua aplicação.
$_GET, $_POST,
JSON ou qualquer entrada externa. Use PDO com prepared statements.
SQL Injection é uma técnica usada para manipular consultas SQL por meio de dados enviados pelo usuário. Quando a aplicação monta uma query misturando texto SQL com valores externos sem proteção, um atacante pode alterar o comportamento da consulta.
Em vez de enviar apenas um e-mail, nome ou ID, a pessoa pode enviar um trecho de SQL malicioso. Se o sistema concatenar esse valor diretamente na query, o banco pode interpretar o conteúdo como comando SQL.
Isso pode causar problemas como:
Veja um exemplo comum de código vulnerável em uma tela de login.
query($sql);
$usuario = $resultado->fetch();
Esse código é perigoso porque os valores de $email e $senha foram colocados diretamente dentro da query.
Se alguém enviar um valor malicioso, a consulta pode ser manipulada.
O problema da concatenação é que o banco não consegue diferenciar com segurança o que é comando SQL e o que é valor enviado pelo usuário.
Exemplo ruim:
Se $id vier corretamente como 10, a consulta funciona.
Mas se o valor vier malformado, a query pode se comportar de forma inesperada.
Por isso, dados externos nunca devem entrar diretamente dentro da string SQL.
A forma recomendada é preparar a consulta e enviar os valores separadamente.
prepare($sql);
$stmt->execute([
':id' => $id
]);
$usuario = $stmt->fetch();
Nesse exemplo, :id é um parâmetro nomeado.
O valor real é enviado separadamente no execute.
Isso reduz o risco de SQL Injection porque o valor não é interpretado como parte do comando SQL.
Antes de usar prepared statements, vamos criar uma conexão PDO com boas configurações.
PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
]
);
} catch (PDOException $e) {
error_log('Erro de conexão: ' . $e->getMessage());
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Erro interno no servidor.'
]);
exit;
}
A configuração PDO::ATTR_EMULATE_PREPARES => false ajuda o PDO a usar prepared statements reais quando suportado pelo driver.
Para buscar um usuário por ID, podemos validar o parâmetro e usar bindValue.
false,
'message' => 'ID inválido.'
]);
exit;
}
$sql = "SELECT id, nome, email FROM usuarios WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$usuario = $stmt->fetch();
if (!$usuario) {
http_response_code(404);
echo json_encode([
'success' => false,
'message' => 'Usuário não encontrado.'
]);
exit;
}
echo json_encode([
'success' => true,
'data' => $usuario
]);
Aqui temos duas camadas de proteção: validação do ID e prepared statement.
Em sistemas de login, além de evitar SQL Injection, também é importante nunca comparar senha em texto puro.
O correto é salvar a senha com password_hash e validar com password_verify.
false,
'message' => 'E-mail e senha são obrigatórios.'
]);
exit;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(422);
echo json_encode([
'success' => false,
'message' => 'Informe um e-mail válido.'
]);
exit;
}
$sql = "SELECT id, nome, email, senha FROM usuarios WHERE email = :email LIMIT 1";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':email' => $email
]);
$usuario = $stmt->fetch();
if (!$usuario || !password_verify($senha, $usuario['senha'])) {
http_response_code(401);
echo json_encode([
'success' => false,
'message' => 'E-mail ou senha inválidos.'
]);
exit;
}
echo json_encode([
'success' => true,
'message' => 'Login realizado com sucesso.',
'data' => [
'id' => $usuario['id'],
'nome' => $usuario['nome'],
'email' => $usuario['email']
]
]);
Repare que a senha não entra na query. Primeiro buscamos o usuário pelo e-mail com segurança.
Depois validamos a senha usando password_verify.
Para cadastrar dados, também devemos usar prepared statements.
false,
'message' => 'Preencha todos os campos obrigatórios.'
]);
exit;
}
$senhaHash = password_hash($senha, PASSWORD_DEFAULT);
$sql = "INSERT INTO usuarios (nome, email, senha) VALUES (:nome, :email, :senha)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':nome' => $nome,
':email' => $email,
':senha' => $senhaHash
]);
echo json_encode([
'success' => true,
'message' => 'Usuário cadastrado com sucesso.',
'data' => [
'id' => $pdo->lastInsertId()
]
]);
Mesmo sendo um INSERT, os dados externos continuam sendo enviados como parâmetros.
Atualizações também devem ser protegidas.
false,
'message' => 'ID inválido.'
]);
exit;
}
$sql = "UPDATE usuarios SET nome = :nome, email = :email WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':nome', $nome);
$stmt->bindValue(':email', $email);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
echo json_encode([
'success' => true,
'message' => 'Usuário atualizado com sucesso.'
]);
O id foi tratado como inteiro e os demais campos foram passados como parâmetros.
Para excluir registros, nunca use o ID diretamente na query.
false,
'message' => 'ID inválido.'
]);
exit;
}
$sql = "DELETE FROM usuarios WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
echo json_encode([
'success' => true,
'message' => 'Usuário excluído com sucesso.'
]);
Em sistemas reais, muitas vezes é melhor usar exclusão lógica, como um campo deleted_at ou ativo,
em vez de apagar definitivamente.
Um erro comum é achar que LIKE não pode ser usado com prepared statements.
Pode sim, desde que o parâmetro seja montado corretamente.
prepare($sql);
$stmt->execute([
':busca' => '%' . $busca . '%'
]);
$resultados = $stmt->fetchAll();
echo json_encode([
'success' => true,
'data' => $resultados
]);
O valor com % é passado como parâmetro, não concatenado dentro da query.
Em APIs com filtros, você pode montar condições dinamicamente, mas os valores devem continuar parametrizados.
prepare($sql);
$stmt->execute($params);
$usuarios = $stmt->fetchAll();
Esse modelo é útil para APIs com busca, filtros por status, categoria, data, usuário ou tipo.
Parâmetros de ordenação exigem cuidado especial. Em geral, não é possível parametrizar nomes de colunas da mesma forma que valores. Por isso, use uma lista branca.
Como order_by e order_dir entram na estrutura da query, eles precisam ser limitados a valores conhecidos.
Não é o ideal. Funções como addslashes ou tentativas manuais de escapar strings não substituem prepared statements.
O problema é que escapar manualmente pode falhar dependendo do banco, charset, driver e contexto da consulta. Com PDO preparado corretamente, você separa o comando SQL dos valores.
Prepared statement protege a consulta, mas não substitui validação de dados. Mesmo usando PDO, você ainda deve validar se os valores fazem sentido.
Exemplos:
Segurança boa combina prepared statements, validação, controle de acesso e tratamento de erros.
Em produção, mensagens de erro do banco não devem aparecer na resposta da API ou na tela do usuário.
Evite retornar algo como:
SQLSTATE[42S22]: Column not found: 1054 Unknown column...
Esse tipo de mensagem pode revelar detalhes internos da aplicação. O melhor é registrar o erro em log e retornar uma mensagem genérica.
prepare($sql);
$stmt->execute($params);
} catch (PDOException $e) {
error_log('Erro SQL: ' . $e->getMessage());
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Erro interno no servidor.'
]);
exit;
}
ORDER BY;addslashes como solução principal;password_hash e password_verify em logins;Além de proteger o código, é importante configurar o usuário do banco com as permissões necessárias e nada além disso.
Por exemplo, se uma aplicação só precisa ler dados em determinada área, não faz sentido usar um usuário com permissão para excluir tabelas.
Reduzir privilégios ajuda a limitar danos caso algum problema de segurança aconteça.
O frontend pode ser ignorado facilmente. A proteção precisa estar no backend.
Mesmo IDs devem ser validados e enviados como parâmetros.
Escapar manualmente não é tão seguro quanto usar prepared statements.
Colunas e direções de ordenação devem passar por lista branca.
Mensagens internas devem ir para logs, não para a resposta pública.
Abaixo temos um endpoint completo de listagem de usuários com busca, status, ordenação segura e prepared statements.
PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
]
);
$busca = isset($_GET['busca']) ? trim($_GET['busca']) : '';
$status = isset($_GET['status']) ? trim($_GET['status']) : '';
$orderBy = $_GET['order_by'] ?? 'id';
$orderDir = strtolower($_GET['order_dir'] ?? 'desc');
$colunasPermitidas = ['id', 'nome', 'email', 'created_at'];
$direcoesPermitidas = ['asc', 'desc'];
if (!in_array($orderBy, $colunasPermitidas, true)) {
$orderBy = 'id';
}
if (!in_array($orderDir, $direcoesPermitidas, true)) {
$orderDir = 'desc';
}
$where = [];
$params = [];
if ($busca !== '') {
$where[] = "(nome LIKE :busca OR email LIKE :busca)";
$params[':busca'] = '%' . $busca . '%';
}
if ($status !== '') {
$statusPermitidos = ['ativo', 'inativo'];
if (!in_array($status, $statusPermitidos, true)) {
jsonResponse(422, [
'success' => false,
'message' => 'Status inválido.'
]);
}
$where[] = "status = :status";
$params[':status'] = $status;
}
$whereSql = '';
if (!empty($where)) {
$whereSql = 'WHERE ' . implode(' AND ', $where);
}
$sql = "
SELECT id, nome, email, status, created_at
FROM usuarios
{$whereSql}
ORDER BY {$orderBy} {$orderDir}
LIMIT 50
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$usuarios = $stmt->fetchAll();
jsonResponse(200, [
'success' => true,
'data' => $usuarios
]);
} catch (PDOException $e) {
error_log('Erro PDO: ' . $e->getMessage());
jsonResponse(500, [
'success' => false,
'message' => 'Erro interno ao consultar os dados.'
]);
} catch (Throwable $e) {
error_log('Erro inesperado: ' . $e->getMessage());
jsonResponse(500, [
'success' => false,
'message' => 'Erro interno no servidor.'
]);
}
SQL Injection é uma falha grave, mas pode ser evitada com práticas corretas no PHP. A principal regra é nunca concatenar valores externos diretamente na consulta SQL.
Usando PDO com prepared statements, validação de dados, lista branca para ordenação e tratamento adequado de erros, sua aplicação fica muito mais segura contra manipulações de consulta.
Essa proteção deve ser aplicada em login, cadastro, filtros, buscas, APIs, painéis administrativos e qualquer ponto do sistema que interaja com o banco de dados.
SQL Injection é uma falha que acontece quando dados enviados pelo usuário são inseridos diretamente em uma consulta SQL, permitindo manipular o comportamento da query.
A forma mais recomendada é usar PDO com prepared statements, passando os valores como parâmetros em vez de concatenar diretamente no SQL.
Sim, quando usado corretamente com prepared statements e parâmetros. O PDO ajuda a separar o comando SQL dos valores enviados pelo usuário.
Não deve ser usado como proteção principal. O ideal é usar prepared statements em vez de tentar escapar manualmente os valores.
Sim. Prepared statements protegem a consulta, mas a validação garante que os dados façam sentido para a regra da aplicação.
Sim. Basta montar o valor com % e passá-lo como parâmetro, por exemplo '%' . $busca . '%'.