Chaves de API
Autenticação entre sistemas, com geração, armazenamento, escopo e rotação das chaves.
Os servidores desta página foram executados nas cinco linguagens e receberam os mesmos sete pedidos. A medição dos hashes, o log do Nginx e a varredura com o gitleaks estão no nível Conceito.
Autenticação por chave, em cada linguagem
Cada versão tem as mesmas peças:
criarChave: gera o prefixo e 32 bytes aleatórios, guarda só o SHA-256 e devolve a chave, que o cliente vê uma única vez.autenticar: lê a chave do cabeçalhoAuthorization, calcula o hash e busca o registro, recusando chaves revogadas e expiradas.- A rota: 401 sem uma chave válida, 403 quando a chave não tem o escopo da ação.
import crypto from "node:crypto";
import http from "node:http";
type Registro = { cliente: string; escopos: string[]; expiraEm: Date | null; revogada: boolean };
const PREFIXO = "loja_live_";
// O banco guarda só o hash; a chave aparece para o cliente uma única vez
const hash = (chave: string) => crypto.createHash("sha256").update(chave).digest("hex");
const chaves = new Map<string, Registro>(); // hash -> registro (em produção, uma tabela)
function criarChave(cliente: string, escopos: string[], expiraEm: Date | null = null): string {
const chave = PREFIXO + crypto.randomBytes(32).toString("base64url"); // 256 bits aleatórios
chaves.set(hash(chave), { cliente, escopos, expiraEm, revogada: false });
return chave;
}
function autenticar(req: http.IncomingMessage): Registro | null {
const [tipo, chave] = (req.headers.authorization ?? "").split(" ");
if (tipo !== "Bearer" || !chave?.startsWith(PREFIXO)) return null; // nunca na URL
const registro = chaves.get(hash(chave)); // busca pelo hash, como num índice único
if (!registro || registro.revogada) return null;
if (registro.expiraEm && registro.expiraEm < new Date()) return null;
return registro;
}
const escopoDaRota: Record<string, string> = { GET: "pedidos:ler", POST: "pedidos:escrever" };
http.createServer((req, res) => {
const registro = autenticar(req);
if (!registro) {
res.writeHead(401, { "WWW-Authenticate": "Bearer" });
return res.end();
}
if (!registro.escopos.includes(escopoDaRota[req.method ?? ""])) {
res.statusCode = 403;
return res.end();
}
res.end(JSON.stringify({ cliente: registro.cliente }));
}).listen(3000);
// Chaves de exemplo, impressas só para o teste
const a = criarChave("parceiro-a", ["pedidos:ler"]);
const b = criarChave("parceiro-b", ["pedidos:ler"]);
chaves.get(hash(b))!.revogada = true;
const c = criarChave("parceiro-c", ["pedidos:ler"], new Date(Date.now() - 1000));
console.log(JSON.stringify({ a, b, c }));import hashlib
import json
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response
PREFIXO = "loja_live_"
@dataclass
class Registro:
cliente: str
escopos: list[str]
expira_em: datetime | None = None
revogada: bool = False
def hash_da_chave(chave: str) -> str:
# O banco guarda só o hash; a chave aparece para o cliente uma única vez
return hashlib.sha256(chave.encode()).hexdigest()
chaves: dict[str, Registro] = {} # hash -> registro (em produção, uma tabela)
def criar_chave(cliente: str, escopos: list[str], expira_em: datetime | None = None) -> str:
chave = PREFIXO + secrets.token_urlsafe(32) # 256 bits aleatórios
chaves[hash_da_chave(chave)] = Registro(cliente, escopos, expira_em)
return chave
def autenticar(request: Request) -> Registro | None:
tipo, _, chave = request.headers.get("authorization", "").partition(" ")
if tipo != "Bearer" or not chave.startswith(PREFIXO): # nunca na URL
return None
registro = chaves.get(hash_da_chave(chave)) # busca pelo hash, como num índice único
if registro is None or registro.revogada:
return None
if registro.expira_em and registro.expira_em < datetime.now(timezone.utc):
return None
return registro
ESCOPO_DA_ROTA = {"GET": "pedidos:ler", "POST": "pedidos:escrever"}
app = FastAPI()
@app.api_route("/pedidos", methods=["GET", "POST"])
def pedidos(request: Request):
registro = autenticar(request)
if registro is None:
return Response(status_code=401, headers={"WWW-Authenticate": "Bearer"})
if ESCOPO_DA_ROTA[request.method] not in registro.escopos:
return Response(status_code=403)
return JSONResponse({"cliente": registro.cliente})
# Chaves de exemplo, impressas só para o teste
a = criar_chave("parceiro-a", ["pedidos:ler"])
b = criar_chave("parceiro-b", ["pedidos:ler"])
chaves[hash_da_chave(b)].revogada = True
c = criar_chave("parceiro-c", ["pedidos:ler"], datetime.now(timezone.utc) - timedelta(seconds=1))
print(json.dumps({"a": a, "b": b, "c": c}), flush=True)package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"sync"
"time"
)
const prefixo = "loja_live_"
type Registro struct {
Cliente string
Escopos []string
ExpiraEm time.Time // zero: não expira
Revogada bool
}
var (
mu sync.RWMutex
chaves = map[string]*Registro{} // hash -> registro (em produção, uma tabela)
)
// O banco guarda só o hash; a chave aparece para o cliente uma única vez
func hashDaChave(chave string) string {
soma := sha256.Sum256([]byte(chave))
return hex.EncodeToString(soma[:])
}
func criarChave(cliente string, escopos []string, expiraEm time.Time) string {
bruto := make([]byte, 32) // 256 bits aleatórios
rand.Read(bruto)
chave := prefixo + base64.RawURLEncoding.EncodeToString(bruto)
mu.Lock()
chaves[hashDaChave(chave)] = &Registro{Cliente: cliente, Escopos: escopos, ExpiraEm: expiraEm}
mu.Unlock()
return chave
}
func autenticar(r *http.Request) *Registro {
chave, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || !strings.HasPrefix(chave, prefixo) { // nunca na URL
return nil
}
mu.RLock()
registro := chaves[hashDaChave(chave)] // busca pelo hash, como num índice único
mu.RUnlock()
if registro == nil || registro.Revogada {
return nil
}
if !registro.ExpiraEm.IsZero() && registro.ExpiraEm.Before(time.Now()) {
return nil
}
return registro
}
var escopoDaRota = map[string]string{"GET": "pedidos:ler", "POST": "pedidos:escrever"}
func main() {
http.HandleFunc("/pedidos", func(w http.ResponseWriter, r *http.Request) {
registro := autenticar(r)
if registro == nil {
w.Header().Set("WWW-Authenticate", "Bearer")
w.WriteHeader(http.StatusUnauthorized)
return
}
if !slices.Contains(registro.Escopos, escopoDaRota[r.Method]) {
w.WriteHeader(http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]string{"cliente": registro.Cliente})
})
// Chaves de exemplo, impressas só para o teste
a := criarChave("parceiro-a", []string{"pedidos:ler"}, time.Time{})
b := criarChave("parceiro-b", []string{"pedidos:ler"}, time.Time{})
chaves[hashDaChave(b)].Revogada = true
c := criarChave("parceiro-c", []string{"pedidos:ler"}, time.Now().Add(-time.Second))
fmt.Printf("{\"a\":%q,\"b\":%q,\"c\":%q}\n", a, b, c)
http.ListenAndServe(":3000", nil)
}import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class App {
static final String PREFIXO = "loja_live_";
static final SecureRandom ALEATORIO = new SecureRandom();
static final class Registro {
final String cliente;
final List<String> escopos;
final Instant expiraEm; // null: não expira
volatile boolean revogada;
Registro(String cliente, List<String> escopos, Instant expiraEm) {
this.cliente = cliente;
this.escopos = escopos;
this.expiraEm = expiraEm;
}
}
// hash -> registro (em produção, uma tabela)
static final Map<String, Registro> CHAVES = new ConcurrentHashMap<>();
// O banco guarda só o hash; a chave aparece para o cliente uma única vez
static String hash(String chave) throws Exception {
var soma = MessageDigest.getInstance("SHA-256")
.digest(chave.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(soma);
}
static String criarChave(String cliente, List<String> escopos, Instant expiraEm)
throws Exception {
byte[] bruto = new byte[32]; // 256 bits aleatórios
ALEATORIO.nextBytes(bruto);
var chave = PREFIXO + Base64.getUrlEncoder().withoutPadding().encodeToString(bruto);
CHAVES.put(hash(chave), new Registro(cliente, escopos, expiraEm));
return chave;
}
static Registro autenticar(HttpExchange ex) throws Exception {
var cabecalho = ex.getRequestHeaders().getFirst("Authorization");
// Só no cabeçalho, nunca na URL
if (cabecalho == null || !cabecalho.startsWith("Bearer " + PREFIXO)) return null;
var registro = CHAVES.get(hash(cabecalho.substring(7))); // busca pelo hash
if (registro == null || registro.revogada) return null;
if (registro.expiraEm != null && registro.expiraEm.isBefore(Instant.now())) return null;
return registro;
}
static final Map<String, String> ESCOPO_DA_ROTA =
Map.of("GET", "pedidos:ler", "POST", "pedidos:escrever");
static void tratar(HttpExchange ex) throws IOException {
try (ex) {
var registro = autenticar(ex);
if (registro == null) {
ex.getResponseHeaders().set("WWW-Authenticate", "Bearer");
ex.sendResponseHeaders(401, -1);
return;
}
if (!registro.escopos.contains(ESCOPO_DA_ROTA.get(ex.getRequestMethod()))) {
ex.sendResponseHeaders(403, -1);
return;
}
var corpo = ("{\"cliente\":\"" + registro.cliente + "\"}")
.getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(200, corpo.length);
ex.getResponseBody().write(corpo);
} catch (Exception erro) {
ex.sendResponseHeaders(500, -1);
}
}
public static void main(String[] args) throws Exception {
var servidor = HttpServer.create(new InetSocketAddress(3000), 0);
servidor.createContext("/pedidos", App::tratar);
servidor.start();
// Chaves de exemplo, impressas só para o teste
var a = criarChave("parceiro-a", List.of("pedidos:ler"), null);
var b = criarChave("parceiro-b", List.of("pedidos:ler"), null);
CHAVES.get(hash(b)).revogada = true;
var c = criarChave("parceiro-c", List.of("pedidos:ler"), Instant.now().minusSeconds(1));
System.out.printf("{\"a\":\"%s\",\"b\":\"%s\",\"c\":\"%s\"}%n", a, b, c);
}
}using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
const string Prefixo = "loja_live_";
// hash -> registro (em produção, uma tabela)
var chaves = new ConcurrentDictionary<string, Registro>();
// O banco guarda só o hash; a chave aparece para o cliente uma única vez
string Hash(string chave) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(chave)));
string CriarChave(string cliente, string[] escopos, DateTimeOffset? expiraEm = null)
{
var chave = Prefixo + Base64Url(RandomNumberGenerator.GetBytes(32)); // 256 bits aleatórios
chaves[Hash(chave)] = new Registro(cliente, escopos, expiraEm);
return chave;
}
static string Base64Url(byte[] b) =>
Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
Registro? Autenticar(HttpRequest req)
{
var cabecalho = req.Headers.Authorization.ToString();
if (!cabecalho.StartsWith("Bearer " + Prefixo)) return null; // nunca na URL
if (!chaves.TryGetValue(Hash(cabecalho["Bearer ".Length..]), out var registro)) return null;
if (registro.Revogada) return null;
if (registro.ExpiraEm is { } expira && expira < DateTimeOffset.UtcNow) return null;
return registro;
}
var escopoDaRota = new Dictionary<string, string>
{
["GET"] = "pedidos:ler",
["POST"] = "pedidos:escrever",
};
var app = WebApplication.Create(args);
app.MapMethods("/pedidos", ["GET", "POST"], (HttpContext ctx) =>
{
var registro = Autenticar(ctx.Request);
if (registro is null)
{
ctx.Response.Headers.WWWAuthenticate = "Bearer";
return Results.StatusCode(401);
}
if (!registro.Escopos.Contains(escopoDaRota[ctx.Request.Method]))
return Results.StatusCode(403);
return Results.Ok(new { cliente = registro.Cliente });
});
// Chaves de exemplo, impressas só para o teste
var a = CriarChave("parceiro-a", ["pedidos:ler"]);
var b = CriarChave("parceiro-b", ["pedidos:ler"]);
chaves[Hash(b)].Revogada = true;
var c = CriarChave("parceiro-c", ["pedidos:ler"], DateTimeOffset.UtcNow.AddSeconds(-1));
Console.WriteLine($$"""{"a":"{{a}}","b":"{{b}}","c":"{{c}}"}""");
app.Run("http://0.0.0.0:3000");
record Registro(string Cliente, string[] Escopos, DateTimeOffset? ExpiraEm)
{
public bool Revogada { get; set; }
}O mapa em memória faz o papel da tabela, para o exemplo rodar sozinho. As três chaves impressas na saída também só existem para o teste.
O resultado, nas cinco linguagens
| Pedido | Esperado | Nas cinco |
|---|---|---|
GET /pedidos sem chave | 401 | 401 |
| Chave inventada, com o prefixo certo | 401 | 401 |
Chave do parceiro A, com o escopo pedidos:ler | 200 | 200 |
POST /pedidos com a chave do parceiro A, sem pedidos:escrever | 403 | 403 |
| Chave revogada do parceiro B | 401 | 401 |
| Chave expirada do parceiro C | 401 | 401 |
Chave válida do parceiro A, enviada em ?api_key= | 401 | 401 |
O que muda de uma linguagem para outra
| Bytes aleatórios | Base64 para URL | SHA-256 em hexadecimal | |
|---|---|---|---|
| TypeScript | crypto.randomBytes | toString("base64url") | createHash("sha256") |
| Python | secrets.token_urlsafe | já incluído | hashlib.sha256(...).hexdigest() |
| Go | crypto/rand | base64.RawURLEncoding | sha256.Sum256 e hex |
| Java | SecureRandom | Base64.getUrlEncoder().withoutPadding() | MessageDigest e HexFormat |
| C# | RandomNumberGenerator.GetBytes | ToBase64String com troca de caracteres | SHA256.HashData e ToHexStringLower |
Repare que a busca é feita pelo hash, num mapa, e não comparando a chave recebida com cada chave guardada. Por isso a comparação em tempo constante não é necessária aqui: o atacante não controla o hash que está sendo procurado, e o tempo da busca não revela nada útil sobre as chaves.
A tabela
CREATE TABLE chaves_api (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
cliente_id bigint NOT NULL REFERENCES clientes (id),
nome text NOT NULL, -- "ERP da filial Sul"
hash text NOT NULL UNIQUE, -- SHA-256 da chave inteira
final text NOT NULL, -- últimos 4 caracteres, para a tela
escopos text[] NOT NULL,
expira_em timestamptz,
revogada_em timestamptz,
ultimo_uso_em timestamptz,
ultimo_uso_ip inet,
criada_em timestamptz NOT NULL DEFAULT now()
);A busca de cada pedido usa o índice criado pelo UNIQUE em hash. Atualizar ultimo_uso_em em todo pedido gera uma escrita por leitura; uma saída comum é atualizar só quando o valor guardado tiver mais de alguns minutos.
A regra na varredura de segredos
A configuração do gitleaks usada no teste, que soma a regra da chave às regras padrão:
[extend]
useDefault = true
[[rules]]
id = "loja-api-key"
description = "Chave de API da Loja"
regex = '''loja_(?:live|test)_[A-Za-z0-9_-]{43}'''Rode a varredura na integração contínua e num gancho antes do commit (gitleaks git no repositório, ou gitleaks dir numa pasta). No teste, só essa regra encontrou a chave com prefixo no meio de um texto. A primeira versão, com (live|test) em vez de (?:live|test), registrou só a palavra live como segredo.
Lista de revisão
Geração
- A chave usa o gerador criptográfico e tem pelo menos 128 bits aleatórios (de preferência 256)?
- Tem um prefixo do produto e do ambiente?
- É mostrada ao cliente uma única vez?
Armazenamento
- O banco guarda só o hash, com índice único, e os últimos caracteres para a tela?
- O hash é rápido (SHA-256), e não bcrypt ou Argon2?
Uso
- A chave só é aceita no cabeçalho, nunca na URL?
- Chaves revogadas e expiradas são recusadas?
- Cada chave tem escopos, e as rotas conferem o escopo?
- Há limite de requisições por chave?
- O último uso é registrado?
Ciclo de vida
- O cliente pode ter várias chaves ativas, para fazer a rotação sem parar?
- Há uma regra na varredura de segredos, e um plano para quando uma chave vazar?
- Chaves sem uso há muito tempo são removidas?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Implementar autenticação por chave de API
Quero implementar autenticação por chave de API na minha aplicação. Pergunte qual linguagem, framework e banco eu uso. Vamos em etapas: 1. A geração da chave com prefixo e 256 bits aleatórios. 2. A tabela com o hash SHA-256 em índice único, escopos, validade e revogação. 3. O middleware que lê o cabeçalho, busca pelo hash e recusa chaves revogadas ou expiradas. 4. A conferência do escopo em cada rota. 5. Testes com chave válida, inventada, revogada, expirada, sem escopo e enviada na URL. Em cada etapa, me passe os comandos para testar com curl.
Configurar a varredura de segredos
Quero configurar a varredura de segredos no meu repositório e na integração contínua. Pergunte qual serviço de repositório e de integração contínua eu uso e quais formatos de chave o meu sistema e os meus fornecedores usam. Explique, com a configuração completa: 1. O gitleaks ou outra ferramenta, com as regras padrão. 2. Uma regra própria para o prefixo das minhas chaves, com um teste que prove que ela encontra a chave. 3. O gancho antes do commit e a etapa na integração contínua. 4. A varredura do histórico inteiro, e o que fazer quando ela encontrar algo antigo. 5. Como tratar falsos positivos sem desligar a regra.
Revisar uma implementação de chaves de API
Vou colar abaixo o código de geração, armazenamento e verificação de chaves de API de uma aplicação. Revise para produção. Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho: 1. A chave usa um gerador criptográfico e tem pelo menos 128 bits aleatórios? 2. O banco guarda só o hash, com um hash rápido e índice único? 3. A chave é aceita só no cabeçalho? 4. Chaves revogadas e expiradas são recusadas? 5. Há escopos conferidos em cada rota? 6. O cliente pode ter várias chaves, para fazer a rotação? 7. O último uso é registrado, e há limite de requisições por chave? Para cada problema, descreva o risco e mostre a correção. Código: [cole aqui]