# Como vincular vários métodos de login a uma conta

Um usuário logado vincula a segunda e a terceira forma de entrar, desvincula uma, troca a senha e encerra todas as sessões. Cada passo é uma chamada REST a um backend Parse, com os códigos respondidos e o caso que o servidor deixa quebrar.

URL: https://www.back4app.com/blog/pt/como-vincular-varios-metodos-de-login-a-uma-conta
Publicado: 2026-11-07 · Testado: 2026-10-23
Testado em: Backend: Back4app, Parse Server 7.5.2, Cloud Code em Node 19.9, plano Free · Cliente: Parse JS SDK 8.6.2, Google Identity Services, Express 5 no Back4app Containers · Checagem local: parse-server 7.5.4 em PostgreSQL 17
Publisher: Back4app Engineering

A página de docs de todo provedor termina no mesmo lugar: um botão, um usuário, pronto. As perguntas começam na semana seguinte. Um convidado que jogou por uma hora quer manter o progresso, então entra com o Google. Alguém cria conta com senha e depois clica no botão do Google com o mesmo e-mail. Um celular se perde e a sessão dele precisa morrer.

Este post é a semana depois da página de docs. Uma conta, várias formas de entrar, num backend Parse hospedado no Back4app, sem servidor de auth próprio: o navegador fala com o backend, o backend fala com o Google. Tudo foi executado em 20 e 23 de outubro de 2026, contra a plataforma ao vivo, e os códigos e textos abaixo são citados das respostas dela.

**Teste, faça o deploy, leia:** a demo roda em [socialloginstarter-6om29ktc.b4a.run](https://socialloginstarter-6om29ktc.b4a.run) com um botão do Google de verdade, login como convidado e e-mail/senha. Contas criadas lá podem ser apagadas a qualquer momento.

O repositório é [github.com/templates-back4app/social-login-starter](https://github.com/templates-back4app/social-login-starter), MIT.

## O que "vincular" significa num backend Parse

Um usuário Parse tem uma linha em `_User`, e essa linha pode carregar várias credenciais ao mesmo tempo: um username e uma senha, e um objeto `authData` com uma chave por provedor. Entrar com qualquer uma delas resolve para a mesma linha.

Não há tabela de "identidades" separada nem etapa de merge. Vincular é um `PUT` no seu próprio usuário com uma nova chave em `authData`, e desvincular é o mesmo `PUT` com essa chave definida como `null`.

Esse é o modelo inteiro, e a tabela abaixo é o protocolo inteiro. As linhas de convidado, senha, desvínculo e logout foram exercitadas com chamadas REST cruas contra o backend ao vivo; as duas linhas do Google descrevem o que o Parse Server faz com um token válido, o que esta rodada só verificou pelo lado da recusa, com tokens forjados.

| Ação na interface | O que o navegador envia | O que o Parse Server faz |
|---|---|---|
| Continuar como convidado | `POST /users {authData:{anonymous:{id}}}` | cria um usuário (201) ou loga o mesmo convidado de novo (200); nova sessão nos dois casos |
| Entrar com Google | `POST /users {authData:{google:{id, id_token}}}` | verifica o ID token junto ao Google, depois cria ou loga (caminho documentado; tokens forjados recusados, veja a tabela de segurança) |
| Criar conta / Entrar | `POST /users {username,password,email}` / `POST /login` | o caminho clássico |
| Vincular e-mail/senha | `PUT /users/<id> {username,password,email}` com a sessão | define a senha, **responde com um novo session token e revoga os antigos**, mantém o id de convidado |
| Vincular Google | `PUT /users/<id> {authData:{google:{…}}}` | verifica o token, vincula; um id já usado em outra conta responde 208 (documentado; não executado com uma conta real) |
| Desvincular Google ou id de convidado | `PUT /users/<id> {authData:{google:null}}` | remove o provedor; a sessão continua válida; remover a última forma de entrar é permitido pelo Parse Server e **recusado pelo hook do repositório** |
| Sair | `POST /logout` | encerra só esta sessão |
| Sair de todos os dispositivos | Cloud function `signOutEverywhere` | apaga toda outra linha de `_Session` do chamador (não é nativo) |

![A tela deslogada da demo: um botão Continue as guest, um botão Sign in with Google e um formulário de e-mail e senha com Log in e Sign up](/blog/blog-assets/link-several-sign-in-methods-to-one-account/01-signed-out-acme.jpg)

## A coisa toda em três requisições

Antes dos detalhes, aqui está o fluxo de vínculo como o navegador o envia, com o Parse JS SDK. Cada chamada é uma requisição REST, e nada mais é necessário para ir de um convidado a uma conta com três formas de entrar.

```js
// 1. Start as a guest: POST /users with an anonymous id the SDK generates and keeps
const user = await Parse.AnonymousUtils.logIn();

// 2. Link e-mail and password: PUT /users/<id> with the current session.
//    The answer carries a NEW session token; every previous session of this user is revoked.
user.set('username', 'jane.doe.acme');
user.setPassword('a-long-passphrase');
user.setEmail('jane.doe@acme.example');
await user.save();

// 3. Link Google: the Google Identity Services button hands you an ID token; PUT it under authData.
//    Parse Server verifies the signature and audience against the client id you configured.
await user.save({ authData: { ...(user.get('authData') || {}), google: { id: payload.sub, id_token: credential } } });

// Unlink: the same PUT with null. The hook refuses to remove the last method.
await user._unlinkFrom('anonymous');
```

O passo 3 usa `save()` puro em vez do `linkWith()` do SDK, e o passo 2 usa `set('username')` em vez de `setUsername()`, por um motivo que as próximas seções medem: os dois helpers descartam o id de convidado em silêncio. Tudo depois deste ponto é o que acontece ao redor dessas chamadas: colisões, sessões, as regras que faltam no servidor e os números.

## Para quem é este post

Você tem um provedor funcionando a partir de uma página de docs e precisa do resto do ciclo de vida: um segundo método na mesma conta, colisões, o que expira quando e como encerrar sessões. Você aceita que o backend seja o dono da identidade e não quer rodar um servidor OAuth.

Duas coisas ficam fora do escopo de propósito. O app não tenta manter "um usuário por pessoa" entre provedores: uma conta Google e uma conta com senha com o mesmo e-mail são dois usuários, a menos que o usuário logado as vincule, e o post explica por quê mais abaixo.

Roles, edição de perfil e SSO pertencem a outros posts; o [post sobre cadastro e recuperação de senha](/blog/add-signup-login-password-reset-without-an-auth-server) cobre o lado do e-mail.

## Os provedores, e quais deles este post executa

A dashboard do Back4app lista cinco provedores sociais em *Authentication*: Apple, Google, Facebook, X e VK. Os nomes no Parse são `apple`, `google`, `facebook`, `twitter` e `vkontakte`. A demo roda o Google, mais os dois provedores que não precisam de console nenhum: convidados anônimos e e-mail/senha.

| Provedor | Neste post | Por quê |
|---|---|---|
| Google | ao vivo na demo | um console gratuito, um Web client id, sem revisão para os escopos básicos |
| Convidado anônimo | ao vivo na demo | nativo do Parse Server; o navegador guarda um id aleatório |
| E-mail/senha | ao vivo na demo | nativo; o post anterior cobre os e-mails de redefinição |
| GitHub | **não disponível** | não é oferecido pelo Back4app; `authData.github` respondeu HTTP 500 "Internal server error" no backend ao vivo |
| Apple | documentado, não executado | precisa de uma conta de desenvolvedor paga e de um domínio verificado |
| Facebook | documentado, não executado | login público exige App Review; o modo de desenvolvimento permite só usuários de teste (página de App Review da Meta, lida em outubro de 2026) |
| VK | documentado, não executado | o adapter respondeu "Vk only works with enableInsecureAuth: true" a uma sondagem, então exige uma configuração de servidor que não perseguimos |

A linha do GitHub merece uma frase, porque a rodada de viabilidade de 20 de outubro descobriu isso do jeito difícil. Um cliente que envia `authData.github` não recebe um erro limpo de "provedor não suportado"; o adapter do Parse Server falha antes, e a resposta é um 500.

O hook `beforeSave` do repositório mantém uma lista de permitidos com os cinco provedores mais `anonymous`, de modo que um provedor ligado na dashboard por acidente ainda não consegue criar um usuário até que a lista seja alterada de propósito.

## A página, o hook e as duas funções

O cliente é uma página HTML, uma folha de estilo e 206 linhas de JavaScript, servidas por um app Express de 65 linhas cujas únicas tarefas são arquivos estáticos, um `/config.js` com as chaves públicas e um `/healthz`. O Parse JS SDK e o Google Identity Services são carregados das suas CDNs.

O botão do Google entrega à página um ID token, um JWT assinado pelo Google. A página decodifica o payload para mostrar o e-mail e então chama `Parse.User.logInWith('google', { authData: { id, id_token } })`. Isso é um `POST /users`; o servidor busca as chaves públicas do Google, confere a assinatura e a audience contra o client id da dashboard, e só então toca no banco de dados.

O arquivo de Cloud Code tem um hook e duas funções, e cada um existe por um motivo que o servidor não cobre:

- **`beforeSave(_User)`** recusa mudanças de identidade por qualquer um que não seja o dono ou a master key, mesmo quando a ACL da linha foi alargada por engano; mantém a lista de provedores permitidos; e mantém um booleano `hasPassword`. O Parse nunca diz a um cliente se um usuário tem senha, nem com a master key, mas o hook roda antes do hashing e vê o texto puro, então consegue definir a flag. Um cliente que envia `hasPassword: true` tem o valor sobrescrito.
- **`linkInfo`** devolve o que o painel precisa e o SDK não consegue ver: a flag acima, o `createdAt` e o `expiresAt` da sessão atual, e quantas sessões a conta tem.
- **`signOutEverywhere`** apaga toda linha de `_Session` do chamador exceto a que fez a chamada, com a master key, e devolve a contagem.

## O ciclo de vida, um resultado registrado por caso

Cada linha abaixo é um teste em `test/lifecycle.test.mjs`, executado contra o backend ao vivo em 23 de outubro de 2026, como chamadas REST cruas com a REST key. Nada na tabela é previsão; a suíte imprime os códigos e os textos, e são estes.

| Caso | O que o backend ao vivo fez |
|---|---|
| Novo convidado, depois o mesmo convidado de novo | 201 com um usuário novo, depois 200 com o mesmo objectId e um novo session token |
| Convidado vincula username, e-mail e senha | 200; a resposta trouxe um **novo session token**; o token antigo passou a responder 209 "Invalid session token"; `authData.anonymous` **mantido** |
| Uma segunda sessão de convidado aberta durante o vínculo | revogada também: 209 |
| Entrar com a senha vinculada | 200, mesmo objectId |
| Outro usuário pega o mesmo username | 400, código 202 "Account already exists for this username." |
| Outro usuário pega o mesmo e-mail | 400, código 203 "Account already exists for this email address." |
| Desvincular o id de convidado de um usuário com senha | 200; a sessão continuou válida; o id de convidado antigo então criou um usuário **novo** |
| Desvincular o último método de um convidado sem senha | 200 do Parse Server sozinho em 23 de outubro, conta órfã; **400, código 119 "Cannot remove the last sign-in method"** com o hook no ar em 27 de outubro |
| Sair no dispositivo 2 | dispositivo 1 continua válido, dispositivo 2 responde 209 |
| Trocar a senha por uma sessão | 200 com um token novo; o token antigo do chamador e toda outra sessão respondem 209 |
| Sair de todos os dispositivos | a função revogou as outras e o chamador continuou funcionando |

![A demo depois que um convidado vinculou e-mail e senha: o painel Linked methods mostra e-mail/senha ativo como jane.doe.acme, id de convidado vinculado, Google não vinculado, e o registro armazenado com hasPassword true e uma sessão criada às 13:18:03](/blog/blog-assets/link-several-sign-in-methods-to-one-account/03-password-linked-acme.jpg)

Três dessas linhas mudam como você escreve o cliente.

**Vincular uma senha substitui a sessão.** A resposta ao `PUT` traz um token novo, e toda sessão anterior daquele usuário some, outros dispositivos incluídos. O SDK troca o token na aba atual; outra aba aberta descobre isso na próxima requisição, quando recebe um 209.

Na captura acima, o `createdAt` da sessão passou de 13:17:13 para 13:18:03 na mesma conta. A página mostra esse timestamp exatamente por esse motivo.

**Colisões são trabalho do backend, e ele diz isso em palavras.** A demo mostra o texto do próprio backend ao lado do código. Na captura abaixo, um segundo convidado tentou vincular o e-mail que a primeira conta já possui.

![A demo mostrando o erro Error 203: Account already exists for this email address. acima do formulário de vínculo de uma segunda conta de convidado](/blog/blog-assets/link-several-sign-in-methods-to-one-account/04-email-collision-acme.jpg)

**O Parse Server permite deixar uma conta órfã, então o hook precisa impedir.** Um convidado sem senha que desvincula o id de convidado fica com uma sessão funcionando e uma conta que nenhuma credencial alcança de novo; registramos HTTP 200 para essa requisição em 23 de outubro.

A primeira versão deste post deixou a proteção para a interface, que desabilita o último botão de desvínculo. Uma regra de interface não é regra quando o mesmo post mostra chamadas REST diretas.

O hook agora recusa uma atualização sem master key que deixaria a conta sem provedor e sem senha: a mesma requisição responde 400, código 119, *Cannot remove the last sign-in method: link another one first*, e a sessão e a conta ficam intactas. Vincule uma senha antes e o desvínculo passa.

O teste que antes documentava a conta órfã agora afirma a recusa, localmente e no backend ao vivo.

## Por que não fundir contas pelo e-mail

Um login com Google cujo e-mail coincide com uma conta com senha existente é um usuário novo no Parse, e este post mantém assim. Fundir pelo e-mail significaria confiar que quem controla aquela conta Google também controla a conta com senha, e a única prova disso é entrar nas duas.

É isso que vincular a partir de uma sessão logada é: o usuário prova a primeira conta por estar nela, depois prova a segunda ao entrar nela.

A consequência prática é uma linha de interface. Quando um cadastro com senha falha com 203 porque um usuário do Google já possui aquele e-mail, diga ao usuário para entrar com o Google e vincular uma senha pelo painel. A demo mostra o texto do backend; seu app pode mostrar a frase melhor.

## Sessões: dois relógios, e o que as encerra

Um session token neste backend foi emitido com expiração um ano depois da criação: o `expiresAt` no registro armazenado é `createdAt` mais 365 dias, em toda sessão que criamos.

O ID token do Google é um relógio separado. Ele é consumido uma vez, no login ou no vínculo, e o backend o guarda em `authData` mas nunca mais precisa dele para atender uma requisição. A expiração desse token não desloga ninguém.

O que encerra uma sessão, pela tabela acima: `POST /logout` para a sessão que chama; uma senha definida ou trocada por uma sessão, para todas elas; e `signOutEverywhere` para todas menos a do chamador. Nada mais no ciclo de vida tocou em `_Session`. Desvincular um provedor não tocou.

## Testes de segurança, como requisições cruas

Um cliente no navegador tem o Application ID e a JavaScript key, e os dois são públicos. Os testes em `test/security.test.mjs` enviam o que um cliente hostil enviaria, direto para a REST API com essas chaves, e o backend ao vivo respondeu:

| Tentativa | Resposta do backend ao vivo |
|---|---|
| `GET /users/me` sem sessão | 400, código 209 |
| `GET /users` sem sessão | 404, código 101 "Permission denied, user needs to be authenticated." |
| `GET /users` com sessão | 200, e a lista contém só a linha do próprio chamador |
| `GET /users/<other>` com sessão | código 101, como se a linha não existisse |
| `PUT /users/<other> {username}` com a sessão de outro usuário | 400, código 206 "Insufficient auth." |
| o mesmo, depois que a ACL da vítima foi alargada para escrita pública | recusado pelo hook: 119 "Only the account owner can change its sign-in methods." |
| `PUT` no próprio usuário com `hasPassword: true` | 200, e a flag continua false |
| `authData.google` com um token que não é JWT | 404, código 101 "provided token does not decode as JWT" |
| `authData.google` sem `id_token` | 404, código 101 "id token is invalid for this user." |
| `authData.google` com um JWT RS256 autoassinado | 404, código 101 "secret or public key must be provided", depois que o servidor buscou as chaves do Google e não encontrou um key id correspondente |
| `authData.github` | 500, código 1 "Internal server error." |
| qualquer Cloud function sem sessão | 400, código 209 |

A linha do token autoassinado é a que prova o caminho de verificação: o servidor foi ao endpoint de certificados do Google, procurou o key id do header forjado e recusou quando não havia nenhum. Um token forjado com um key id real do Google falharia na verificação de assinatura.

Duas configurações tornam o resto da tabela verdadeiro, e `npm run setup` aplica as duas por uma Cloud function, para que nenhum script precise da master key.

Usuários novos recebem uma ACL só do dono, e as permissões por classe de `_User` exigem sessão para find, get, update e delete e escondem `email`, `authData`, `hasPassword` e `emailVerified` de todos menos o dono.

Os campos protegidos importam mesmo que você nunca alargue uma ACL: sem eles, um usuário logado listando `_User` veria os e-mails dos outros usuários.

## Latência, medida

`npm run measure` cria seus próprios usuários, roda 20 aquecimentos e 100 requisições cronometradas por cenário, e apaga o que criou. O cliente foi uma máquina fora dos EUA; os arquivos estão em `docs/evidence/`.

| Cenário (backend ao vivo, 23 de outubro de 2026) | p50 | p95 | mín | máx | erros |
|---|---|---|---|---|---|
| Novo login como convidado (`POST /users`, id anônimo novo) | 160 ms | 430 ms | 150 ms | 457 ms | 0 |
| Convidado que volta (`POST /users`, id anônimo conhecido) | 164 ms | 248 ms | 144 ms | 267 ms | 0 |
| Login por e-mail (`POST /login`) | 228 ms | 288 ms | 215 ms | 313 ms | 0 |

Criar um usuário custa mais na cauda do que logar um: o p95 da primeira linha é o único outlier do conjunto. A linha do e-mail é cerca de 60 ms mais lenta que as linhas de convidado; a rodada local abaixo aponta o bcrypt como motivo, embora a rodada ao vivo não consiga separá-lo do resto do trabalho do servidor.

O caminho do Google está ausente dessa tabela de propósito. Ele precisa de um token assinado pelo Google para uma conta real, que só uma sessão de navegador consegue produzir, e um script não consegue entrar no Google. O roteiro manual do fim o mede com o painel Network do próprio navegador, cinco execuções, e o CSV tem uma linha reservada para o resultado.

Para dar escala, os mesmos três cenários num parse-server local com um PostgreSQL no mesmo processo levaram 1,7 ms, 1,8 ms e 42,6 ms no p50. Ali, sem rede, os 40 ms entre as linhas de convidado e a linha do e-mail são o hash da senha; a diferença entre local e ao vivo é a rede, e é por isso que os números ao vivo são os que importam.

## O que encontramos enquanto construíamos

- **O primeiro deploy de Cloud Code num backend novo pode reportar sucesso e não enviar nada.** A prova é uma requisição, não o diálogo: `POST /functions/linkInfo` com a REST key responde 209 quando o código está no ar e 141 "Invalid function" quando não está.
- **O Cloud Code roda em Node 19.9 e o Containers faz o build em Node 22.** Nada neste repositório precisa de um runtime mais novo, mas um hook que usasse uma API do Node 20 só falharia depois do deploy.
- **A master key nunca saiu da dashboard.** Setup, limpeza dos testes e o teste que alarga a ACL são Cloud functions protegidas por `SETUP_ENABLED` em `cloud/config.js`. Enquanto ele está em true, qualquer um com o app id e uma chave de cliente pode chamá-las, então o último passo antes dos usuários reais é defini-lo como false, fazer o upload, fazer o deploy e provar com o curl do README.

Três coisas menores custaram tempo e pertencem a uma lista de troubleshooting, não à história:

- O botão do Google segue o idioma do navegador: numa máquina alemã ele dizia *Über Google anmelden*. `hl=en` na URL do script e `locale: 'en'` em `renderButton` o fixam.
- Um botão do Google renderizado dentro de uma seção oculta tem 0 px de largura, porque o Google Identity Services o dimensiona no momento da renderização. A página renderiza os botões de novo sempre que uma view fica visível.
- Um app criado com a origem *Upload files* do Containers não consegue fazer o próprio redeploy: uma troca de plano disparou um redeploy que falhou com *This deployment has no uploaded code*, e a borda respondeu 502 até que *Action → Redeploy latest code* o reconstruiu a partir do código que a página Source Code ainda guardava.

## Como fazer o deploy

1. **Backend.** Crie um app na dashboard do Back4app e copie o Application ID, a JavaScript key e a REST key para o `.env`. Deixe `MASTER_KEY` vazio; nada aqui precisa dele.

2. **Google.** No console do Google Cloud, crie um cliente OAuth do tipo *Web application*, adicione suas origens em *Authorized JavaScript origins* (esquema e host exatos, sem path) e cole o client id em Back4app → Authentication → Google e no `.env` como `GOOGLE_CLIENT_ID`.

3. **Cloud Code.** Dashboard → Cloud Code → faça o upload de `cloud/main.js` e `cloud/config.js`, confirme o badge *Files pending deploy (2)*, Deploy. Verifique com a requisição acima.

4. **Schema.** `npm run setup` adiciona a coluna `hasPassword` e as permissões por classe pela função `setupSchema` e imprime a sondagem: `GET /users` sem sessão respondendo 101.

5. **Testes e tempos.** `TEST_SERVER_URL=https://parseapi.back4app.com npm test`, depois `npm run measure`. Os dois criam seus próprios usuários e os apagam.

6. **Containers.** Faça o deploy de um web app a partir do repositório com o Node.js Buildpack, porta 8080, health check `/healthz` e exatamente quatro variáveis de ambiente: `APP_ID`, `JS_KEY`, `SERVER_URL` e `GOOGLE_CLIENT_ID`. O servidor imprime toda variável que conhece em `/config.js`, que todo visitante pode ler, então nunca adicione uma chave que não seja pública.

7. **Google de novo.** Adicione a URL do deploy às origens autorizadas. Até propagar, o botão registra *The given origin is not allowed for the given client ID*.

8. **Antes de abrir ao público.** Defina `SETUP_ENABLED: false`, faça o upload de `cloud/config.js`, Deploy, e confirme que `POST /functions/setupSchema` agora responde 119.

## Quando não fazer desse jeito

Se você precisa ser o provedor de identidade de outras aplicações, ou seus clientes entram com o provedor SAML ou OpenID Connect da empresa deles, você precisa de um serviço de identidade, e produtos como Auth0, Keycloak ou Ory existem para isso. O `authData` do Parse é um repositório de credenciais por aplicação, não um broker de identidade.

Se "uma pessoa, uma conta" é uma exigência legal ou de cobrança, vincular a partir de uma sessão logada é necessário, mas não suficiente. Você também precisaria de um e-mail verificado antes de qualquer fusão, e de um caminho de suporte para a pessoa que tem duas contas e não lembra de nenhuma.

E note o que este post não executou: o roteiro manual do Google no navegador, que precisa de uma conta Google real. O `docs/manual-google-check.md` do repositório é o roteiro de dez minutos para isso.

Ele cobre um login novo, um login repetido, vincular uma senha a um usuário do Google, vincular o Google a um convidado, a colisão 208, cinco logins cronometrados, desvínculo, sair de todos os dispositivos e um client id errado. Quando tiver sido executado na demo, o arquivo de evidência dele substitui este parágrafo.

## O repositório

[github.com/templates-back4app/social-login-starter](https://github.com/templates-back4app/social-login-starter), MIT. `public/` é o cliente inteiro, `cloud/main.js` o hook e as funções, `scripts/` o setup, o servidor local e a medição de latência, `test/` os 16 testes REST crus que rodam localmente contra o parse-server com PostgreSQL e, com uma variável de ambiente, contra o backend ao vivo.

`docs/notes/build-diary.md` registra o que foi observado durante a construção, incluindo os dez comportamentos vistos localmente que foram verificados de novo no Back4app, e `docs/evidence/` guarda os CSVs por trás de cada número acima.
