Segurança
Esta página descreve mecanismos que existem em código rodando. Onde um controle está ausente ou é mais fraco do que parece, isso é declarado em vez de omitido.
A Veridia não passou por teste de intrusão, auditoria nem certificação por ninguém. Nada nesta página é uma atestação de terceiros. É uma descrição da implementação, oferecida para que você mesmo possa avaliá-la.
Chaves de API
Duas famílias, distinguidas pelo prefixo, com capacidades genuinamente diferentes.
| Prefixo | Onde ela pertence | Pode fazer |
|---|---|---|
qv_pub_ / qv_pubt_ | Navegador, app mobile, código-fonte da página | POST /v1/verify/init, POST /v1/verify/submit |
qv_sec_ / qv_sect_ | Apenas no servidor | O acima, mais ler vereditos |
As variantes com t são as chaves de modo de teste. Repare no formato: é qv_pubt_, não qv_pub_test_.
Ler um veredito exige uma chave secreta. GET /v1/verify/{id} rejeita uma chave publicável com 401 secret_key_required antes de fazer qualquer outra coisa. A razão é simples: uma chave publicável fica visível para qualquer um que abra a sua página, então, se ela pudesse ler vereditos, qualquer pessoa poderia ler o resultado de KYC de qualquer verificação cujo identificador conseguisse nomear.
Como as chaves são armazenadas
O valor bruto da chave é exibido uma única vez, na criação. O que o banco de dados guarda é um hash SHA-256 dele, mais uma cópia criptografada em AES-256-GCM. A cópia criptografada existe por um motivo específico: na revogação, o valor bruto precisa ser recuperado para apagar a entrada correspondente do cache de edge, de modo que a revogação de fato se propague em vez de apenas ficar registrada.
A revogação é imediata — e não existe rotação
Revogar uma chave apaga sua entrada no edge imediatamente. Toda requisição em andamento que a utilize começa a falhar no mesmo instante. Não existe fluxo de rotação, nem período de carência, nem "a chave antiga continua funcionando por N minutos".
A sequência segura é: criar a nova chave, implantá-la em todos os lugares, verificar que o tráfego está fluindo por ela e só então revogar a antiga. Revogar primeiro vai derrubar a sua integração.
allowedOrigins não faz o que o nome sugere
Cada chave carrega uma lista allowedOrigins. Leia isto antes de tratá-la como um controle de acesso.
- Uma lista vazia permite todas as origens. As chaves são criadas com uma lista vazia, então, por padrão, a checagem não roda de jeito nenhum.
- Ela só se aplica a navegadores. A checagem só é avaliada para chaves publicáveis em requisições que carregam um header
Origin. Um cliente do lado do servidor não enviaOrigine passa incondicionalmente. - Ela compara o hostname puro. A entrada é
app.example.com, nãohttps://app.example.come nãohttps://app.example.com:443. Curingas como*.example.comsão suportados, e não correspondem ao ápiceexample.compor si só.
Para o que este recurso serve: restringir em quais páginas o seu widget roda. O que ele não é: um controle que impeça a sua chave publicável de ser usada em outro lugar. Trate a chave publicável como pública, porque ela é.
Isolamento entre tenants
Toda requisição autenticada resolve para exatamente um tenant, obtido a partir da chave de API. Não existe parâmetro de tenant em nenhum corpo de requisição — enviar um não tem efeito nenhum.
Ler uma verificação compara o tenant dono dela com o do chamador, e responde 404 verification_not_found em vez de 403 quando eles diferem. Um 403 confirmaria que o identificador existe e pertence a outra pessoa; um 404 não revela nada.
Autenticação de upload
As imagens capturadas não vão para armazenamento de objetos com URL pré-assinada. Elas são enviadas para um endpoint dedicado na própria API, e é por isso que esse endpoint autentica de forma diferente de todos os outros:
- A chave de API como bearer não é aceita ali. A autenticação é um
X-Veridia-Upload-Tokende vida curta, com escopo de uma única verificação e válido por 15 minutos. - Esse token é retornado dentro do objeto
headersde cada slot de upload em/v1/verify/init. Encaminhe esses headers literalmente; não os reconstrua à mão. - O corpo precisa ser um JPEG real — os magic bytes
FF D8 FFsão verificados — e ter no máximo 8 MB.
Rotear os bytes através da API em vez de direto para o armazenamento é o que torna possível vincular a imagem enviada à verificação que a solicitou.
Rate limiting
Duas camadas independentes.
| Camada | Limite | Aplicada |
|---|---|---|
| Por IP | 100 requisições / 60 s | Antes da autenticação, em todas as rotas |
Por tenant, /v1/verify/init | 60 / minuto | Depois da autenticação |
Por tenant, /v1/verify/submit | 30 / minuto | Depois da autenticação |
Por tenant, GET /v1/verify/{id} | 600 / minuto | Depois da autenticação |
Ele roda antes da autenticação, então um 429 pode chegar antes de existir qualquer tenant. E como cada imagem é um upload separado — uma verificação com desafio ativo de prova de vida faz cerca de 20 deles —, vários usuários mobile atrás do mesmo NAT de operadora podem esgotar 100 requisições por minuto enquanto os seus números por tenant ainda parecem intocados. Se você está depurando um 429 que não faz sentido diante da tabela por tenant, normalmente é por isso.
Assinaturas de webhook
Toda entrega carrega:
Veridia-Signature: t=<unix_seconds>,v1=<hmac_sha256_hex>
Veridia-Event: verification.approved
Veridia-Event-Id: evt_<hex>
O MAC é HMAC-SHA256(secret, "<t>." + raw_body_bytes). Duas consequências que vale internalizar:
Verifique sobre os bytes brutos. Fazer o parse do JSON e reserializá-lo produz bytes diferentes e, portanto, um digest diferente, mesmo que cada chave e cada valor sejam idênticos. Capture o corpo antes que o seu framework o toque.
O timestamp vive dentro do header de assinatura. Não existe header separado de timestamp, e não existe X-Veridia-Signature — o header não tem prefixo X-.
Por que uma tolerância de 5 minutos é suficiente
Os reintentos se estendem por aproximadamente 12,6 minutos, o que naturalmente levanta a questão de se a janela de replay precisa ser alargada para acompanhar. Ela não precisa.
O despachante calcula uma assinatura nova a cada tentativa. A sexta tentativa carrega um t carimbado instantes antes de ser enviada, não um de doze minutos atrás. Uma tolerância de 300 segundos aceita todo reintento legítimo.
Não a alargue. Cada minuto extra é tempo extra durante o qual uma entrega capturada pode ser reproduzida contra você, comprado sem nenhum benefício.
Comparando assinaturas
Compare em tempo constante — crypto.timingSafeEqual, hmac.compare_digest, hash_equals.
Uma armadilha: crypto.timingSafeEqual no Node lança exceção quando os dois buffers têm comprimentos diferentes. Um atacante que envie v1=ab transforma o seu handler em uma exceção não capturada e um 500. Cheque os comprimentos primeiro e rejeite se não baterem, e só então compare.
A proteção contra SSRF nas URLs de webhook
Uma URL de webhook é um dado fornecido pelo atacante: qualquer tenant digita uma no painel, e o backend então faz uma requisição de saída para ela. Sem uma proteção, isso transforma a plataforma em uma sonda para infraestrutura que o tenant não conseguiria alcançar de outro jeito — o corpo da resposta nunca volta para ele, mas o código de status e o tempo já bastam para enumerar o que existe.
Toda URL é verificada ao ser salva e novamente imediatamente antes de cada envio. A segunda verificação é a que importa: um hostname que resolve publicamente ao ser salvo pode resolver para 127.0.0.1 um minuto depois.
A verificação rejeita:
Qualquer esquema que não seja https | Os payloads carregam PII de identidade; transporte em texto claro é recusado de imediato |
| Qualquer porta que não seja 443 ou 8443 | Outras portas são muito mais frequentemente um serviço interno do que um endpoint real |
Credenciais user:pass@host | Uma forma clássica de fazer uma URL parecer um host e resolver para outro |
Loopback (127.0.0.0/8, ::1) | |
Link-local (169.254.0.0/16) | Onde vivem os endpoints de metadados de instância na nuvem — o alvo de maior valor no host |
| Faixas privadas (RFC 1918 e equivalentes IPv6) | |
NAT de operadora (100.64.0.0/10) | Não é "privada" pela definição da biblioteca padrão, mas nunca é um endpoint público legítimo |
| Não especificado, multicast, reservado | |
IPv6 mapeado de IPv4 (::ffff:127.0.0.1) | Desembrulhado e reverificado, ou passaria por baixo de tudo acima |
Se um hostname resolve para qualquer endereço proibido, o nome inteiro é rejeitado mesmo quando as outras respostas parecem boas — um nome que resolve tanto para um endereço público quanto para um privado tem o formato de um ataque de rebinding, então aceitação parcial não é segura.
As entregas não seguem redirecionamentos, e expiram em 10 segundos no total, com 5 segundos de timeout de conexão.
Consequência prática para desenvolvimento local
http://localhost:3000 não consegue receber webhooks. Não é "desaconselhado" — é recusado, pelo esquema e pelo endereço. Use um túnel que termine TLS em um hostname público.
Transporte
A API é servida sobre TLS. A entrega de webhooks é exclusivamente por TLS, e texto claro é rejeitado pela proteção acima.
Controles que não existem
Declarados abertamente, porque uma lacuna declarada é honesta e uma lacuna escondida é o que afunda uma auditoria.
- Sem criptografia em nível de aplicação nas colunas de identidade.
extracted_name,extracted_document_numbereextracted_date_of_birthsão armazenados como colunas simples no MySQL. Qualquer criptografia que exista abaixo disso é uma propriedade do disco e do provedor de infraestrutura, não um controle que a Veridia implemente ou possa atestar. Não descreva isso como "criptografado em repouso" na sua própria documentação por nossa conta. - Sem rotação de chaves com janela de sobreposição. Veja acima — a revogação é imediata.
- Sem captura de consentimento. O widget não apresenta, não registra nem carimba o horário de nenhum consentimento. Veja GDPR.
- Sem exclusão ou exportação self-service. Veja Retenção de dados.
- Sem lista publicada de IPs de saída. Se o seu firewall precisa colocar em allowlist as origens de webhooks de entrada, não existe lista para te dar hoje.
webhook_log.attemptsé aproximado. A tabela de histórico por tentativa registra uma contagem de tentativas que pode ser imprecisa sob entrega concorrente. Use a caixa de saída de entregas exibida no painel, e não essa coluna, como o registro do que foi entregue.
Reportando uma vulnerabilidade
Reporte em privado, e não em uma issue pública. Veja Suporte.