Se eu sofrer um ataque e dumparem o banco de dados do brtech, o que o invasor encontra?
Hashes irreversiveis. Nomes aleatorios. Tokens nulos. Zero emails. Zero IPs. Zero user agents.
Nao e "confia que a gente nao vai vazar". E "mesmo que vaze, nao tem o que entregar".
Esse post conta como chegamos nessa arquitetura - e os 6 dias de commits que transformaram uma solucao ingenua em anonimato de verdade.
Dia 1: "So guardar o hash, ne?"
27 de junho. Primeiro commit do auth.ts. O plano parecia simples:
// Solucao ingenua - Dia 1
databaseHooks: {
user: {
create: {
before: async (user) => {
const hash = hmac(user.email)
const existing = await prisma.user.findUnique({ where: { emailHash: hash } })
return {
data: {
...user,
emailHash: hash,
email: null, // "pronto, nao guardei email"
},
}
},
},
},
}
Funcionava? Tecnicamente sim. O email chegava no hook, era hasheado, e o campo email ia como null para o banco.
Mas tinha um problema. O email ainda transitava pelo hook do Better Auth. A lib recebia o payload do Google/GitHub/LinkedIn com o email em texto puro, chamava o before, e so depois descartava. Se alguem logasse o user dentro do hook - ou se o Better Auth fizesse algum log interno - o email estava la.
Era melhor que guardar no banco. Mas nao era anonimato de verdade.
Dia 2: "anonymousId publico e ma ideia"
28 de junho. O schema tinha um campo anonymousId - 4 caracteres alfanumericos que identificavam o usuario publicamente nos perfis.
model User {
anonymousId String? @unique
// ...
}
A ideia era: em vez de mostrar o CUID interno (que e imutavel e poderia ser usado para correlacionar dados entre tabelas), mostramos um ID publico curto.
29 de junho. Removido. O commit ab781c3 deletou o campo do schema, do auth, dos tipos, de tudo.
O problema: qualquer ID estavel e visivel publicamente permite correlacionar contribuicoes ao longo do tempo. Se o usuario aB3x posta um salario na Stone e amanha posta uma review no Nubank, da para juntar as pecas - especialmente se o cargo e senioridade forem parecidos.
Nao ter ID publico nenhum tambem nao funcionava - o usuario precisa ver seu proprio historico. A solucao final foi o anonymousId virar um derivado do emailHash (visivel so para o proprio usuario logado), mas deixamos isso para depois.
Dia 3: "Usuario Anonimo com foto de dado"
2 de julho. Commit b2e1f13. Reescrevemos o before hook para nunca mais deixar o email original tocar campos que vao para o banco:
const generateRandomEmail = () => `${nanoid(16)}@anonimizado.brtech.fyi`
const generateName = () => `Anonimo #${nanoid(7)}`
const generateImage = () => `https://api.dicebear.com/6.x/initials/svg?seed=${nanoid(7)}`
user: {
create: {
before: async (user) => ({
data: {
...user,
emailHash: hashEmail(user.email), // unica coisa que persiste
email: generateRandomEmail(), // falso
name: generateName(), // "Anonimo #aB3xK2m"
image: generateImage(), // avatar DiceBear aleatorio
emailVerified: false,
seniorityLevel: null,
currentRole: null,
},
}),
},
}
Agora o fluxo era:
- Google manda
{ email: "joao@gmail.com", name: "Joao Silva", image: "https://..." } - O hook gera
emailHashvia HMAC-SHA256 - Substitui
email,name,imagepor dados falsos - O banco nunca ve o email real
Mas ainda faltava uma camada. Os tokens OAuth (access token, refresh token) e os dados de sessao (IP, user agent) continuavam sendo persistidos pelo Better Auth automaticamente. Esses tokens sao a chave para acessar a conta Google/GitHub do usuario - se vazarem, o invasor pode se passar por ele.
Dia 4: O lockdown (o commit que mudou tudo)
5 de julho. Commit 32ebbbc. O "privacy lockdown".
Duas descobertas aconteceram nesse dia:
1. O Better Auth persiste tokens OAuth por padrao. Cada login guarda accessToken, refreshToken, idToken, scope na tabela Account. Esses tokens dao acesso a API do Google/GitHub/LinkedIn em nome do usuario.
2. O Better Auth atualiza os tokens em TODO login. O campo account.updateAccountOnSignIn: true (padrao) reescreve os dados da conta a cada sessao.
A solucao foi atacar em todas as frentes:
advanced: {
ipAddress: {
disableIpTracking: true, // nunca armazena IP
},
},
account: {
// Nao atualiza tokens a cada login
updateAccountOnSignIn: false,
create: {
before: async (account) => ({
data: {
...account,
accessToken: null,
refreshToken: null,
idToken: null,
accessTokenExpiresAt: null,
refreshTokenExpiresAt: null,
scope: null,
password: null,
},
}),
after: async (account) => {
// Segunda camada: zera email e imagem do User
// apos o Better Auth terminar de criar a conta
await prisma.user.update({
where: { id: account.userId },
data: {
email: null,
emailVerified: false,
image: null,
},
})
},
},
update: {
before: async (account) => ({
data: {
...account,
accessToken: null,
refreshToken: null,
// ... mesmo tratamento
},
}),
},
},
session: {
create: {
before: async (session) => ({
data: {
...session,
ipAddress: null, // nunca armazena IP
userAgent: null, // nunca armazena user agent
},
}),
},
update: {
before: async (session) => ({
data: {
...session,
ipAddress: null,
userAgent: null,
},
}),
},
},
user: {
update: {
before: async (userData) => ({
data: {
...userData,
email: generateRandomEmail(), // re-randomiza a cada update
emailVerified: false,
},
}),
},
},
O after do account e a jogada mais sutil aqui. Mesmo depois do before limpar os campos, o Better Auth pode popular novamente o User.email e User.image durante a criacao da conta. O after garante que, depois que a lib termina seu trabalho, a gente limpa de novo.
Nesse mesmo dia criei o PRIVACY_DATA_INVENTORY.md - um documento mapeando campo por campo do banco, o que contem, e por que pode ou nao ser anonimizado. Virou a referencia canonica para qualquer decisao futura sobre privacidade.
Dia 5: Cross-provider linking sem email
6 de julho. Commits 5183060 → 673df35 → c7c2b3d.
Agora o banco nao tinha email. Beleza. Mas e o cenario onde o usuario faz login com Google num dia e com GitHub no outro?
Sem email no banco, o Better Auth nao consegue correlacionar as contas. Ele faz findOne(User, { email: "joao@gmail.com" }) e nao acha nada - porque o email no banco e aB3xK2m9nQpL4rSt@anonimizado.brtech.fyi.
A solucao: interceptar as queries do adapter.
O Better Auth usa um adapter de banco de dados para todas as operacoes. Se eu conseguisse interceptar as chamadas findOne no model User filtrando por email, poderia redirecionar para emailHash:
export function withEmailHashLookup(createAdapter) {
return (...args) => {
const factory = createAdapter(...args)
return (options) => wrap(factory(options))
}
}
function wrap(adapter) {
return {
...adapter,
findOne: async ({ model, where, select, join }) => {
// Intercepta queries de User por email
if (
model.toLowerCase() === 'user' &&
where?.length === 1 &&
where[0]?.field === 'email' &&
typeof where[0]?.value === 'string'
) {
// Tenta achar por emailHash primeiro
const hashResult = await adapter.findOne({
model,
where: [{ field: 'emailHash', value: hashEmail(where[0].value) }],
select,
join,
})
if (hashResult) return hashResult
}
// Fallback: busca normal por email
return adapter.findOne({ model, where, select, join })
},
// O wrapper tambem precisa funcionar dentro de transacoes
transaction: async (cb) => {
return adapter.transaction(async (trx) => cb(wrap(trx)))
},
}
}
E na configuracao do auth:
accountLinking: {
enabled: true,
trustedProviders: ['google', 'github', 'linkedin', 'microsoft'],
allowDifferentEmails: false,
updateUserInfoOnLink: false,
},
Agora o fluxo e:
- Usuario faz login com Google →
emailHashgerado, conta criada - Usuario faz login com GitHub (mesmo email) → o adapter intercepta a query por
email, traduz paraemailHash, acha o mesmo usuario - A conta GitHub e linkada ao mesmo usuario sem que o email jamais toque o banco
Bug sutil que quase passou: o adapter usava model === 'User' (case-sensitive) mas o Better Auth internamente chama com 'user' (lowercase) em alguns metodos. Corrigi no commit c7c2b3d mudando para model.toLowerCase() === 'user'. Isso custou uma hora de debug.
Gotcha: pensando agora, nem precisava desse adapter todo. Era so gerar o hash e fazer append de @anonymized.brtech.fyi como email fake - o Better Auth faria o lookup por email normalmente e encontraria o usuario pelo hash deterministico. Sem adapter. Sem wrapper. Sem interceptar query. Mas tambem nao tenho certeza se funcionaria com account linking cross-provider... e ja que ta funcionando assim, vai ficar assim.
Dia 6: Soft delete que apaga ate o hash
Uma vez por mes alguem pede para deletar a conta. A LGPD exige isso.
Mas se eu so marco status: 'deleted', o hash continua la. E hashes podem ser atacados por forca bruta se o espaco de busca for pequeno (ex: "quero saber se joao@gmail.com tem conta no brtech" → gero o hash e comparo).
A solucao: sobrescrever o hash com lixo:
await tx.user.update({
where: { id: session.id },
data: {
status: 'deleted',
deletedAt: now,
emailHash: `deleted-${nanoid(32)}`, // hash original e destruido
},
})
As contribuicoes (salarios, reviews, entrevistas) nao sao deletadas - elas permanecem anonimas, alimentando as medias e rankings. Mas o ponteiro entre elas e o usuario some para sempre.
O que acontece se o usuario criar uma conta nova depois? Vai gerar um emailHash novo, conta nova, historico zerado. A conta antiga e um fantasma - contribuicoes orfas que nao apontam para ninguem.
O que essa arquitetura NAO protege
Para ser honesto, tem limites:
Fingerprinting comportamental. Se voce postar "Sou Senior na Stone, entrei em 2023, ganho R$18k CLT" e amanha tuitar exatamente a mesma frase, a correlacao e externa ao brtech. Nao tem como impedir que o usuario se auto-identifique fora da plataforma.
Metadados de moderacao. Moderadores veem o conteudo das contribuicoes (precisam revisar). Nao veem identidade - mas veem que o usuario Anonimo #aB3xK2m postou review X e salario Y. Se o usuario usar o mesmo cargo e empresa em ambas, e um padrao visivel.
Rotacao do BETTER_AUTH_SECRET. Se eu trocar a chave do HMAC, todos os hashes mudam. Ninguem consegue fazer login na conta antiga. Isso e documentado: rotacionar o secret requer uma migracao planejada que recalcula todos os emailHash do banco. Nao e operacao de emergencia.
A funcao que amarra tudo
No final, 14 linhas de codigo seguram o castelo:
// src/lib/hash-email.ts
export function hashEmail(email: string): string {
const secret = process.env.BETTER_AUTH_SECRET
if (!secret) throw new Error('BETTER_AUTH_SECRET is required')
return crypto
.createHmac('sha256', secret)
.update(email.toLowerCase().trim())
.digest('hex')
}
Deterministico. Irreversivel. Sem sal (o secret e o sal). A mesma funcao e usada em 4 lugares: auth hook, adapter, seed script, e representative lookup. Se algum lugar usar um algoritmo diferente, usuarios duplicados aparecem.
E essa funcao que me deixa dormir tranquilo. Se um dia alguem pedir acesso ao banco com um mandado judicial, o que vou entregar sao hashes HMAC-SHA256, nomes Anonimo # aleatorios, e tokens nulos.
E salarios que nao apontam para ninguem.
O codigo esta no ar. A versao de preview em dev.brtech.fyi tem dados ficticios para testar. A versao oficial em brtech.fyi já tem algumas contribuições, com dados reais.
O repo e aberto: github.com/tuf/brtech (ou onde estiver hospedado).
Top comments (0)