Autenticação
Cada chamada é assinada. Não há token de sessão, não há login: a credencial é a chave, e a prova de que ela é sua é a assinatura.
Os quatro cabeçalhos
| Cabeçalho | Conteúdo |
|---|---|
X-Api-Key | o clientId da credencial |
X-Timestamp | epoch em segundos |
X-Nonce | único por chamada, até 128 caracteres |
X-Signature | HMAC-SHA256 da cadeia canônica, em hexadecimal minúsculo |
Sempre os quatro. Falta de qualquer um responde 401.
A cadeia canônica
Cinco partes, concatenadas com \n:
MÉTODO EM MAIÚSCULAS
/caminho/da/requisicao
timestamp
nonce
sha256(corpo)
O corpo entra como resumo, não inteiro. Corpo vazio vira sha256("") —
que é sempre
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
O host fica de fora. Se você fala com mais de um domínio, a mesma assinatura vale nos dois, e você não precisa reassinar por domínio.
Por que cada parte está lá
Não é cerimônia. A ausência de cada campo abre um ataque concreto:
| Sem… | O que passa a ser possível |
|---|---|
| o método | um GET assinado é reenviado como DELETE |
| o caminho | uma cobrança de R$ 10 é reenviada contra outro endpoint |
| o instante | a mesma chamada vale para sempre |
| o nonce | ela vale dentro da janela de tolerância |
| o corpo | troca-se o valor mantendo a assinatura |
Código
- PHP
- Python
- Node
function assinar(string $metodo, string $caminho, string $corpo, string $secret): array
{
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$cadeia = implode("\n", [
strtoupper($metodo),
$caminho,
$ts,
$nonce,
hash('sha256', $corpo),
]);
return [
'X-Timestamp' => $ts,
'X-Nonce' => $nonce,
'X-Signature' => hash_hmac('sha256', $cadeia, $secret),
];
}
import hashlib, hmac, secrets, time
def assinar(metodo: str, caminho: str, corpo: str, segredo: str) -> dict:
ts = str(int(time.time()))
nonce = secrets.token_hex(16)
cadeia = "\n".join([
metodo.upper(),
caminho,
ts,
nonce,
hashlib.sha256(corpo.encode()).hexdigest(),
])
assinatura = hmac.new(
segredo.encode(), cadeia.encode(), hashlib.sha256
).hexdigest()
return {"X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": assinatura}
import {createHash, createHmac, randomBytes} from 'node:crypto';
export function assinar(metodo, caminho, corpo, segredo) {
const ts = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(16).toString('hex');
const cadeia = [
metodo.toUpperCase(),
caminho,
ts,
nonce,
createHash('sha256').update(corpo).digest('hex'),
].join('\n');
return {
'X-Timestamp': ts,
'X-Nonce': nonce,
'X-Signature': createHmac('sha256', segredo).update(cadeia).digest('hex'),
};
}
:::warning Assine os bytes que você vai enviar Serialize o JSON uma vez, assine essa string, e mande essa mesma string no corpo. Reserializar depois de assinar muda os bytes — a ordem das chaves, um espaço — e a assinatura deixa de conferir por um motivo que não aparece em lugar nenhum. :::
Tolerância e nonce
300 segundos entre X-Timestamp e o relógio do servidor. Fora disso, 401.
Sincronize o relógio do seu servidor. Essa é a causa número um de
invalid_signature numa integração que já funcionou por meses.
O nonce é consumido: repeti-lo dentro da janela responde 401, mesmo com
assinatura correta. Use um valor aleatório por chamada, não um contador que você
reinicia no deploy.
Todos os erros de autenticação respondem igual
Chave desconhecida, assinatura errada, cabeçalho ausente, instante fora da
janela e nonce repetido devolvem todos 401 com
error.code: "invalid_signature".
São deliberadamente indistinguíveis. Diferenciá-los transformaria o endpoint num verificador de quais chaves existem.
Isso significa que a depuração é do seu lado, e vale seguir esta ordem:
- o relógio do servidor está sincronizado (NTP)?
- o corpo assinado é byte a byte o corpo enviado?
- o caminho na cadeia é só o caminho — sem host, com a barra inicial?
- o nonce é novo?
- a chave é do ambiente certo?
Guarde o segredo como segredo
O clientSecret foi mostrado uma única vez, na criação. Não existe "ver de
novo": se você o perdeu, revogue a credencial e crie outra.
E ele nunca sai do seu servidor. Uma chave no front é uma chave publicada — esta API não é para o navegador.