Client secret dentro do APK não é segredo: OAuth em app nativo com PKCE
Qualquer pessoa extrai o client secret de um app instalado. Veja como achar o vazamento no seu build, trocar para cliente público com PKCE e rotacionar o segredo que já saiu.
Faça a experiência com o APK de produção do seu app: descompacte e procure pelo prefixo do client secret. Em menos de um minuto, num app de agenda que atendemos, apareceu o mesmo secret usado pelo backend web, copiado do exemplo de servidor para o código Android porque "precisava para trocar o código pelo token". Esse secret estava em cada aparelho que instalou o app nos últimos dois anos.
A resposta curta: um app instalado é um cliente público, porque qualquer pessoa pode abrir o pacote e ler o que tem dentro. Ele não deve carregar client secret. O fluxo certo é Authorization Code com PKCE, no navegador do sistema, com um cliente OAuth do tipo aplicativo nativo e um redirect que só o seu app recebe. Se um secret já foi embutido, trate como vazado: troque, atualize o backend e só depois limpe o histórico.
Por que não existe segredo dentro do app
O APK e o IPA são arquivos compactados. Strings do código, recursos e configurações ficam legíveis com ferramentas gratuitas, e ofuscação só atrasa quem procura. Não importa se o secret está numa constante, num arquivo de recurso ou dividido em pedaços: o app precisa remontá-lo para usar, e quem controla o aparelho vê o resultado.
O PKCE resolve o problema que o secret tentava resolver. O app gera um valor aleatório, o code_verifier, e manda ao provedor apenas o hash dele, o code_challenge, no início do login. Na hora de trocar o código pelo token, apresenta o verificador original. Um app malicioso que intercepte o código de autorização não consegue trocá-lo, porque não tem o verificador, que nunca saiu da memória do app legítimo. É uma prova criada a cada login, em vez de um segredo fixo compartilhado por todas as instalações.
O guia do Google para apps nativos, que é a sourceUrl deste post, e a RFC 8252, que define boas práticas de OAuth para apps nativos, apontam na mesma direção: navegador do sistema, PKCE e nenhuma dependência de secret. Para tipos de cliente instalado em que o Google ainda entrega um secret, a própria documentação avisa que ele não é tratado como confidencial.
Como corrigir, passo a passo
O exemplo é o app Agenda Clínica, Android em Kotlin, com um provedor OIDC próprio e um backend web que também usa OAuth.
- Procure o secret no build de produção, do jeito que um atacante faria.
- Crie um cliente OAuth separado para o app, do tipo aplicativo nativo ou público, sem secret.
- Use um redirect que só o app recebe: um App Link HTTPS verificado ou um esquema privado baseado no domínio invertido.
- Implemente o fluxo com PKCE, de preferência com a biblioteca AppAuth, que já trata os detalhes.
- Remova o secret do código do app e publique a versão nova.
- Rotacione o secret vazado no cliente do backend, atualizando o servidor antes de desativar o antigo.
- Limpe o histórico do repositório e coloque um detector de segredos no CI.
Achar o vazamento no seu próprio build
unzip -q -o app-release.apk -d apk-extraido
grep -r -a -l "GOCSPX-" apk-extraido
Os secrets mais recentes do Google costumam começar com GOCSPX-; para outros provedores, procure por um trecho do secret real. O grep encontra strings que ficam legíveis no bytecode; para uma busca completa, inclusive em recursos, abra o APK num descompilador como o jadx. Na Agenda Clínica, o resultado apontou para classes.dex, e o descompilador mostrou a constante com o nome do campo.
Gerar verificador e desafio
import android.util.Base64
import java.security.MessageDigest
import java.security.SecureRandom
private const val FLAGS = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
fun gerarCodeVerifier(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.encodeToString(bytes, FLAGS)
}
fun gerarCodeChallenge(verifier: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(verifier.toByteArray(Charsets.US_ASCII))
return Base64.encodeToString(digest, FLAGS)
}
Abrir o login no navegador do sistema
val verifier = gerarCodeVerifier()
val state = gerarCodeVerifier()
// guardar verifier e state para conferir no retorno
val authUri = Uri.parse("https://sso.agendaclinica.example/authorize").buildUpon()
.appendQueryParameter("client_id", "agenda-android")
.appendQueryParameter("redirect_uri", "br.example.agendaclinica:/oauth2redirect")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid profile")
.appendQueryParameter("code_challenge", gerarCodeChallenge(verifier))
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("state", state)
.build()
CustomTabsIntent.Builder().build().launchUrl(context, authUri)
No retorno, o app confere se o state é o mesmo que gerou e troca o código no endpoint de token com grant_type=authorization_code, code, redirect_uri, client_id e code_verifier. Nenhum secret. O código acima mostra o mecanismo; em produção, a biblioteca AppAuth faz esse fluxo, a conferência do state e a troca do código com menos chance de erro. No Google, as bibliotecas oficiais de login para Android e iOS são o caminho recomendado, e as regras de redirect por tipo de cliente mudaram ao longo do tempo, então confira o guia atual antes de cadastrar.
O redirect do exemplo usa um esquema privado no formato de domínio invertido, como sugere a RFC 8252. Um App Link HTTPS verificado é ainda melhor, porque o sistema garante que só o seu app abre aquele endereço. Em .NET MAUI, o mesmo vai-e-volta está detalhado em WebAuthenticator no .NET MAUI.
Rotacionar o que já vazou
Remover o secret do app novo não resolve: as versões antigas continuam instaladas e o secret está na loja, em espelhos de APK e no histórico do Git. Como era o mesmo secret do backend web, a Agenda Clínica criou um novo secret no provedor, atualizou o backend, confirmou que os logins web funcionavam e só então desativou o antigo. A ordem importa, e está descrita em rotação que tem dois passos. Depois, o histórico foi limpo e um detector de segredos entrou no CI, seguindo segredo no Git se troca antes de limpar. O backend, por sua vez, continua usando secret, porque ali ele fica no servidor; veja OAuth de servidor guarda o segredo lá.
Erros comuns
- Copiar o exemplo de servidor web para o app. O secret vai para todos os aparelhos e para quem baixar o APK.
- Reaproveitar o cliente OAuth do backend no app. Um vazamento no app compromete também o login web.
- Confiar em ofuscação. O secret continua extraível; só demora um pouco mais.
- Usar WebView para a página de login. O provedor pode bloquear, e o app passa a ter acesso ao que a pessoa digita.
- Esquecer o
state. O app aceita um retorno que não iniciou, abrindo espaço para falsificação de login. - Limpar o histórico sem rotacionar. O secret antigo segue válido nas cópias que já circulam.
Checklist
- A busca no APK ou IPA de produção não encontra nenhum client secret.
- O app tem cliente OAuth próprio, público, separado do backend.
- O fluxo usa Authorization Code com PKCE e
code_challenge_method=S256. - O login abre no navegador do sistema, não numa WebView.
- O redirect é um App Link verificado ou um esquema privado de domínio invertido.
- O
stateé gerado, guardado e conferido no retorno. - Qualquer secret que já esteve no app foi rotacionado.
- Um detector de segredos roda no CI do repositório do app.
Baixe o pacote que está na loja agora e procure nele: o seu app carrega um client secret?
Referência: https://developers.google.com/identity/protocols/oauth2/native-app?authuser=1&hl=pt_BR