# Como adicionar autenticação a um app web ou Flutter sem escrever um servidor de auth

Uma página web e um app Flutter ganham cadastro, login, logout e recuperação de senha com seis chamadas REST a um backend gerenciado, sem código de servidor que toque em senha. Medimos cada chamada, inclusive o e-mail que nunca chegou.

URL: https://www.back4app.com/blog/pt/como-adicionar-cadastro-login-e-recuperacao-de-senha-sem-servidor-de-auth
Publicado: 2026-10-15 · Testado: 2026-09-17
Testado em: Backend: Back4app, plano Free, USA East · Web: uma página HTML + JavaScript puro · Mobile: Flutter 3 (build web, Chrome) · Terminal: curl no macOS
Publisher: Back4app Engineering

Todo app com contas precisa das mesmas cinco coisas: um jeito de criar conta, um jeito de entrar, um jeito de continuar logado, um jeito de sair e um caminho de volta quando a senha se perdeu. Nenhuma delas é o seu produto. Todas elas são onde um erro custa mais caro.

A resposta habitual é um servidor de auth: um processo que você escreve e opera, que faz hash de senhas, emite tokens, envia e-mails e rotaciona segredos. Este post é sobre não escrevê-lo. Um backend gerenciado já tem uma **classe de usuários** com hashing, sessões, e-mail e uma página hospedada de redefinição. Seu app fala com ele por REST, de um navegador ou de um celular.

Construímos duas vezes no mesmo backend: uma página HTML simples com 91 linhas de JavaScript e um app Flutter com 58 linhas de Dart para a API. Os dois fazem as mesmas seis chamadas. Tudo abaixo foi medido em 17 de setembro de 2026, no Back4app, inclusive o único e-mail que não chegou.

**Pegue o código funcionando:** o repositório companion está em [github.com/templates-back4app/user-auth-starter](https://github.com/templates-back4app/user-auth-starter) — `web/`, `flutter/`, `cloud/main.js` e `auth-check.sh`, que percorre o fluxo inteiro com `curl` e imprime os códigos de status e os tempos que você verá abaixo.

## O que um servidor de auth precisa, de fato, acertar?

Mais do que parece. Aqui está a lista que um servidor escrito à mão precisa cobrir antes de ser seguro para ir ao ar, e onde cada item cai quando um backend gerenciado faz o trabalho.

| O trabalho do servidor de auth | Por que você não pode pular | Quem faz isso aqui |
|---|---|---|
| Fazer hash das senhas, nunca armazená-las | Uma tabela vazada é toda conta | Backend: a coluna `password` mostra `(hidden)` até para você |
| Emitir um token por login e checá-lo a cada requisição | Cookies e JWTs são onde a maioria dos bugs mora | Backend: classe `_Session`, header `X-Parse-Session-Token` |
| Revogar um token no logout | "Saiu" precisa significar isso | Backend: `POST /logout` |
| Rejeitar duplicatas e entrada inválida | Duas contas, um e-mail | Backend: códigos 202 / 203, mais o seu hook |
| Enviar o e-mail de recuperação com link de uso único | O link é uma credencial | Backend: `no-reply@b4a.app`, página hospedada |
| Verificar o endereço de e-mail | Ou qualquer um cria conta como você | Backend, depois de validar um cartão |
| Bloquear força bruta | Adivinhação de senha na velocidade da rede | Configuração da dashboard (pago) ou o seu hook |

Nada nessa tabela roda na sua máquina. O que roda na sua máquina é um formulário e seis chamadas `fetch`.

## Quais seis chamadas REST substituem o servidor de auth?

As mesmas seis, da página web, do Flutter e do `curl`. Toda chamada envia dois headers que identificam o *app*: `X-Parse-Application-Id` e uma chave de cliente (`X-Parse-JavaScript-Key` na web, `X-Parse-Client-Key` no Flutter). Um terceiro header, `X-Parse-Revocable-Session: 1`, pede tokens que `POST /logout` consegue revogar.

![Arquitetura: uma página web e um app Flutter fazem as mesmas seis chamadas REST ao backend Back4app, que mantém as classes _User e _Session, roda um hook beforeSave em _User e envia o e-mail de recuperação de senha cujo link abre uma página hospedada de escolha de senha](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/architecture-diagram.svg)

| Chamada | O que faz | Medido (curl, depois navegador) |
|---|---|---|
| `POST /users` | Cria a conta, devolve `sessionToken` | 201 · 856 ms · 597 ms |
| `POST /login` | Checa a senha, devolve um `sessionToken` novo | 200 · 544 ms · 245 ms |
| `GET /users/me` | Valida o token, devolve o usuário | 200 · 861 ms |
| `POST /requestPasswordReset` | Envia o e-mail de recuperação | 200 `{}` · e-mail 1 s depois |
| `POST /verificationEmailRequest` | Envia o e-mail de verificação (quando ligado) | 200 `{}` · sem e-mail em conta Free |
| `POST /logout` | Revoga o token que chamou | 200 · próximo `GET /users/me` → 209 |

Os primeiros números do `curl` incluem o backend acordando e o handshake TLS a partir de um terminal. Os números do navegador são o que um usuário sente na segunda requisição.

Como medimos: `performance.now()` em volta de cada `fetch` na página web, e os tempos que `auth-check.sh` imprime para o `curl`, uma execução de cada em 17 de setembro de 2026, de um laptop para o backend em USA East.

Os números do `curl` incluem o handshake TLS; os do navegador são uma segunda requisição em conexão já aquecida. O tempo do e-mail é a hora de recebimento na caixa de entrada menos a hora do pedido.

## Como a página web cria a conta, faz login e faz logout?

Um arquivo HTML com três formulários e um painel de logado, e um script. O script guarda um único estado, o session token, em `sessionStorage`: some quando a aba fecha, ilegível de outra origem. Aqui está a parte que importa, o helper `api` e o handler de cadastro:

```javascript
// Stack: vanilla JavaScript (browser) | File: web/app.js
const BASE = window.BACKEND_URL ?? "https://parseapi.back4app.com";
const HEADERS = {
  "X-Parse-Application-Id": window.APP_ID,
  "X-Parse-JavaScript-Key": window.JS_KEY,      // a client key: it identifies the app, not the user
  "X-Parse-Revocable-Session": "1",
  "Content-Type": "application/json",
};

const session = {
  get: () => sessionStorage.getItem("sessionToken"),
  set: (t) => sessionStorage.setItem("sessionToken", t),
  clear: () => sessionStorage.removeItem("sessionToken"),
};

async function api(method, path, body, token) {
  const headers = { ...HEADERS };
  if (token) headers["X-Parse-Session-Token"] = token;
  const r = await fetch(BASE + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
  const data = r.status === 204 ? {} : await r.json();
  if (!r.ok) throw Object.assign(new Error(data.error ?? r.statusText), { code: data.code, status: r.status });
  return data;
}

$("#signup").addEventListener("submit", async (e) => {
  e.preventDefault();
  try {
    const t0 = performance.now();
    const u = await api("POST", "/users", fields(e.target));       // 201 {objectId, createdAt, sessionToken}
    session.set(u.sessionToken);
    status(`signed up in ${Math.round(performance.now() - t0)} ms — check your inbox for the verification e-mail`, "ok");
    e.target.reset(); showMe();
  } catch (err) { status(`${err.code} ${err.message}`, "err"); }
});
```

Login é o mesmo handler contra `/login`. Logout chama `/logout` com o token e depois o apaga, nessa ordem: o servidor esquece o token, depois a aba. `showMe()` roda a cada carregamento da página e chama `GET /users/me` com o token que estiver guardado, então um token revogado ou expirado é detectado na próxima visita, não aceito em silêncio.

![A página web do auth starter antes de qualquer requisição: três formulários, Sign up, Log in e Forgot your password, cada um com seu botão e uma barra de status vazia acima deles](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/1-start.jpg)

Preenchemos o formulário de cadastro. A barra de status informou **signed up in 597 ms**, e o painel abaixo dela é a resposta de `GET /users/me`: username, e-mail, `emailVerified: false` e um `createdAt` de 15:14:58 UTC.

![A página web logo após o cadastro: a barra de status verde diz signed up in 597 ms, e o painel Logged in mostra o JSON do usuário com emailVerified false e os três botões Refresh, Resend verification e-mail e Log out](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/2-signed-up.jpg)

Depois fizemos logout e tentamos o mesmo username com uma senha errada. O backend responde **101 Invalid username/password.** tanto para senha errada quanto para username desconhecido, que é o comportamento certo: o formulário de login não deve contar a um atacante qual metade estava errada.

![A página web após um login que falhou: a barra de status vermelha diz 101 Invalid username/password. acima do formulário Log in com o username ainda preenchido](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/3-wrong-password.jpg)

## Como o mesmo fluxo fica no Flutter?

Idêntico, em Dart, com `package:http` e sem SDK. A classe de API inteira tem 58 linhas. O método `_call` é o helper `api` da página web; os seis métodos abaixo dele são as seis chamadas:

```dart
// Stack: Flutter 3.x / Dart 3.x, package:http | File: lib/auth_api.dart
class AuthApi {
  static const _base = backendUrl;
  static const _headers = {
    'X-Parse-Application-Id': appId,
    'X-Parse-Client-Key': clientKey,      // a client key: identifies the app, not the user
    'X-Parse-Revocable-Session': '1',
    'Content-Type': 'application/json',
  };
  String? sessionToken;                   // kept in memory; persist it with flutter_secure_storage in a real app

  Future> _call(String method, String path, {Map? body}) async {
    final headers = {..._headers, if (sessionToken != null) 'X-Parse-Session-Token': sessionToken!};
    final uri = Uri.parse('$_base$path');
    final r = switch (method) {
      'GET' => await http.get(uri, headers: headers),
      'POST' => await http.post(uri, headers: headers, body: jsonEncode(body ?? {})),
      _ => throw ArgumentError(method),
    };
    final data = r.body.isEmpty ? {} : jsonDecode(r.body) as Map;
    if (r.statusCode >= 400) throw AuthError(r.statusCode, data['code'] as int?, data['error']?.toString() ?? r.reasonPhrase ?? '');
    return data;
  }

  Future> signUp(String username, String email, String password) async {
    final u = await _call('POST', '/users', body: {'username': username, 'email': email, 'password': password});
    sessionToken = u['sessionToken'] as String;
    return u;
  }

  Future> logIn(String username, String password) async {
    final u = await _call('POST', '/login', body: {'username': username, 'password': password});
    sessionToken = u['sessionToken'] as String;
    return u;
  }

  Future> me() => _call('GET', '/users/me');
  Future<void> requestPasswordReset(String email) => _call('POST', '/requestPasswordReset', body: {'email': email});
  Future<void> requestVerificationEmail(String email) => _call('POST', '/verificationEmailRequest', body: {'email': email});

  Future<void> logOut() async {
    try { await _call('POST', '/logout'); } finally { sessionToken = null; }   // revoked server-side, then forgotten
  }
}
```

O `test/auth_api_test.dart` do repo não é um teste unitário com mocks. Ele cria um usuário novo contra o backend real, checa a sessão, faz logout, faz login de novo e afirma que uma senha errada lança um `AuthError` com código 101. `flutter test` passou em 17 de setembro de 2026, que é a única prova que conta.

![A tela única do app Flutter em uma janela do Chrome do tamanho de um celular: campos de username, e-mail e senha com três botões, Create account, Log in e Send reset e-mail](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/5-flutter-start.jpg)

Uma diferença sobre a qual vale ser honesto: a página web guarda o token em `sessionStorage`; a classe Flutter guarda em memória. No celular você quer que ele sobreviva a um restart, e o lugar para isso é o keychain da plataforma (`flutter_secure_storage`), não shared preferences.

## O que chega quando um usuário pede a recuperação de senha?

Um e-mail, um segundo depois. Pedimos a recuperação para o usuário `ana` às 14:57:37 UTC por `POST /requestPasswordReset` e a mensagem chegou à caixa de entrada às 14:57:38, de `no-reply@b4a.app`, com o assunto *Password Reset Request for user-auth-starter*. O corpo é texto puro com um link.

O link aponta para o backend, que redireciona (302) para uma página hospedada em `/apps/choose_password`: um título com o nome do seu app, o username, dois campos de senha e um botão *Change Password*. O token está em um campo oculto. Você não escreveu essa página, e no plano Free não pode reestilizá-la, mas ela funciona.

![A página hospedada de redefinição de senha, intitulada Reset Your Password for user-auth-starter, com New Password for ana, dois campos de senha, um botão Change Password e o logo Powered by Back4App](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/4-choose-password-page.jpg)

Dois detalhes que valem copiar para o seu formulário. `POST /requestPasswordReset` devolve `200 {}` exista ou não o e-mail, então sua UI deve dizer *se esse endereço tem uma conta, um e-mail de recuperação está a caminho*, exatamente como o repo faz. E o link de recuperação não loga ninguém: depois de trocar a senha o usuário volta ao seu formulário de login, com as sessões intactas.

## Por que nenhum e-mail de verificação chegou?

Porque a verificação está desligada, e a API não diz isso. Criamos uma conta com uma caixa de entrada descartável real e então chamamos `POST /verificationEmailRequest` com esse endereço. A resposta foi `200 {}`. Nada chegou, nem na hora nem depois. O e-mail de recuperação para a mesma caixa levou um segundo, então entrega não era o problema.

A chave fica na dashboard em **Notifications → Email → Verification**, e em uma conta Free ela está atrás de uma segunda trava: um banner dizendo *Validate your card — In order to enable this feature, you must validate your card.* Os dois toggles abaixo, *Enable Verification* e *Prevent login with unverified email*, ficam acinzentados até isso ser feito. Nenhuma cobrança é informada na página.

![A página Notifications → Email → Verification: um banner Validate your card com um botão Validate Card, e os toggles desabilitados Enable Verification e Prevent login with unverified email](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/8-email-verification-settings.jpg)

Então o estado honesto de um backend Free recém-criado é: recuperação de senha funciona, verificação não, e `emailVerified` fica `false` em todo usuário. A mensagem de cadastro do repo diz *check your inbox for the verification e-mail*; troque essa linha se for ao ar antes de validar o cartão.

Com a verificação ligada, a mesma chamada `POST /verificationEmailRequest` reenvia o e-mail e `GET /users/me` vira `emailVerified` para `true` depois do clique.

## Onde vão as regras de senha quando a página de configurações é somente leitura?

Em um hook. A dashboard tem uma seção **Password Policy** completa em App Settings → Advanced Options: validade do token de reset, uma expressão regular de validação, uma mensagem de erro personalizada, *do not allow username in password*, idade máxima da senha, histórico de senhas e uma seção **Account Lockout** com limite e duração.

No plano Free todo campo está desabilitado atrás de um banner: *Please upgrade your plan to change custom parse options.*

![A página Advanced Options na dashboard, seção Password Policy: Password Validator Pattern, Validation Error Message, Do Not Allow Username, Max Password Age e Max Password History, todos acinzentados no plano Free](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/10-advanced-options-password-policy.jpg)

Sobra o Cloud Code, que roda em todo plano. Um `beforeSave` na classe `_User` roda em todo cadastro e toda atualização de perfil, seja qual for o cliente que enviou, então uma regra escrita uma vez vale igualmente para a página web, o app Flutter e um comando `curl`.

O nosso exige um e-mail, converte para minúsculas e rejeita usernames com menos de três caracteres. Uma segunda função, `whoami`, mostra como uma Cloud Function enxerga quem chama:

```javascript
// Stack: Node.js 22.x | Parse Server 8.x | File: cloud/main.js
// Rules about accounts live in the backend, so the web page, the Flutter app and a curl command all obey them.
Parse.Cloud.beforeSave(Parse.User, (request) => {
  const user = request.object;
  const email = (user.get("email") ?? "").trim().toLowerCase();
  if (!email) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "an e-mail address is required.");
  user.set("email", email);
  const username = (user.get("username") ?? "").trim();
  if (username.length < 3) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "username needs at least 3 characters.");
  user.set("username", username);
});

Parse.Cloud.define("whoami", async (request) => {
  if (!request.user) throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, "log in first.");
  return { id: request.user.id, username: request.user.get("username"), verified: request.user.get("emailVerified") === true };
});
```

Uma regra de tamanho ou complexidade de senha vai no mesmo hook: `request.object.get("password")` é legível no `beforeSave` de um usuário novo, antes do hash, e nunca depois. Um contador de bloqueio são mais algumas linhas com um campo `failedLogins`, zerado em um trigger `afterLogin`.

![O editor do Cloud Code com cloud/main.js aberto no backend user-auth-starter: o hook beforeSave em Parse.User e a Cloud Function whoami, com o botão Deploy no canto superior direito](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/7-cloud-code-user-hook.jpg)

## O que o backend mostra depois dos cadastros?

Três telas, e toda linha nelas foi escrita pela página web, pelo teste Flutter ou pelo `auth-check.sh`. Não definimos nenhum schema à mão.

**Overview: de onde vêm as chaves.** O App ID e, no dropdown Keys, a JavaScript key para a página web e a Client key para o Flutter. São chaves de *cliente*: dizem qual app está chamando, não quem. Embarcá-las em uma página é esperado. A Master key no mesmo dropdown é a que nunca pode sair de um servidor.

![O Overview do backend user-auth-starter: o App ID, o dropdown Keys em Javascript Key com o valor borrado e, à direita, Parse Server 7.5.2, MongoDB 3.6 e a URL da API](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/6-backend-overview-v2.jpg)

**Database: a classe `_User`.** Sete linhas depois dos nossos testes: `web97694` da página web, `fl333903` do teste Flutter, `user16949` do `curl`, `ana` com a caixa descartável que recebeu o e-mail de recuperação, `ab` e `carla` das checagens do hook, e `diana`.

A coluna `password` mostra `(hidden)` em todas elas, para nós também; `emailVerified` está undefined porque a verificação está desligada.

![O Database Browser mostrando a classe _User com sete objetos: username, createdAt, uma coluna password que mostra hidden em toda linha, e a coluna de e-mail](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/9-database-user-class.jpg)

A classe `_Session` ao lado tinha uma linha por login que fizemos, cada uma com seu token, seu usuário e um `expiresAt` um ano à frente, que é a duração padrão de sessão mostrada, somente leitura, em Advanced Options. Fazer logout apaga a linha daquele token e nenhuma outra. Esse é o achado da próxima seção.

## Quais códigos de erro o seu formulário de login vai ver?

O backend responde com um status HTTP e um `code` numérico no corpo. O formulário deve ramificar pelo código; o status é quase sempre 400 ou 404 e diz menos. Estes são os que encontramos em 17 de setembro de 2026:

| O que você fez | HTTP | `code` | `error` |
|---|---|---|---|
| Senha errada, ou username desconhecido | 404 | 101 | `Invalid username/password.` |
| Cadastro com um username que já existe | 400 | 202 | `Account already exists for this username.` |
| Cadastro com um e-mail que já existe | 400 | 203 | `Account already exists for this email address.` |
| Qualquer chamada com token revogado ou inventado | 400 | 209 | `Invalid session token` |
| Cadastro que o hook rejeitou (`ab`) | 400 | 142 | `username needs at least 3 characters.` |
| `whoami` sem token | 400 | 209 | `log in first.` |

E o que não esperávamos. O script `curl` cria a conta (token A), faz login (token B), faz logout com B e então tenta `GET /users/me` com os dois. B recebe 209. **A ainda recebe 200.** Logout revoga a sessão que chamou, não o usuário.

Se "sair de todos os dispositivos" é um recurso de que você precisa, uma Cloud Function com a master key pode apagar toda linha de `_Session` do usuário; a chave de cliente não pode.

## Quando você ainda deve escrever o seu próprio servidor de auth?

Quando a identidade não é sua para guardar. Se seus usuários já existem em um diretório corporativo, o trabalho é federação (SAML, OIDC), e uma classe de usuários tem o formato errado. Se você precisa hoje da política de senha, do bloqueio e das páginas de e-mail personalizadas, aqui elas custam um plano pago, e é justo pesar isso contra uma semana do seu próprio código.

Se você precisa de login social, o backend tem isso em App Settings → Social Auth, mas é assunto para outro post. E se você tem uma regra de compliance dizendo que os hashes de senha precisam ficar em um banco que você administra, nada disto se aplica.

Para todo o resto, as seis chamadas acima são o servidor de auth, e a parte que é sua são 17 linhas de regras.

## O que você adicionaria ao auth starter em seguida?

- **Um armazenamento real de token no mobile.** Troque o `sessionToken` em memória por `flutter_secure_storage` e chame `me()` ao abrir o app, para que uma sessão revogada seja pega antes de a primeira tela renderizar.
- **Bloqueio no hook.** Conte logins que falharam em um par `afterLogin`/`beforeLogin` e recuse depois de cinco em dez minutos; no plano Free esse é o único lugar onde isso pode viver.
- **Sair de todos os dispositivos.** Uma Cloud Function que apaga toda linha de `_Session` de `request.user` com a master key. Doze linhas.
- **Verificação, assim que o cartão for validado.** Ligue *Prevent login with unverified email*, veja `POST /login` começar a recusar usuários não verificados e trate esse código de erro no formulário.
- **Restrinja o que os usuários alcançam.** Um usuário logado ainda é só um session token. Quais linhas ele pode ler e escrever é o próximo post: [Seu frontend fala direto com o banco. Veja como blindá-lo](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to).
