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.

As seis chamadas REST que compõem cadastro, login, checagem de sessão, recuperação de senha, logout e uma sessão rejeitada, cada uma com o código de status e o tempo que medimos em 17 de setembro de 2026, ao lado do e-mail de recuperação de senha que chegou um segundo depois do pedido

Tradução do original em inglês, testado em 17 de setembro de 2026. Código, números e datas são os do original. Read in English

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 — 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 authPor que você não pode pularQuem faz isso aqui
Fazer hash das senhas, nunca armazená-lasUma tabela vazada é toda contaBackend: a coluna password mostra (hidden) até para você
Emitir um token por login e checá-lo a cada requisiçãoCookies e JWTs são onde a maioria dos bugs moraBackend: classe _Session, header X-Parse-Session-Token
Revogar um token no logout“Saiu” precisa significar issoBackend: POST /logout
Rejeitar duplicatas e entrada inválidaDuas contas, um e-mailBackend: códigos 202 / 203, mais o seu hook
Enviar o e-mail de recuperação com link de uso únicoO link é uma credencialBackend: [email protected], página hospedada
Verificar o endereço de e-mailOu qualquer um cria conta como vocêBackend, depois de validar um cartão
Bloquear força brutaAdivinhação de senha na velocidade da redeConfiguraçã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.

O app nunca vê um hash de senha, um segredo de token ou um servidor de e-mail. Ele vê JSON.

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

ChamadaO que fazMedido (curl, depois navegador)
POST /usersCria a conta, devolve sessionToken201 · 856 ms · 597 ms
POST /loginCheca a senha, devolve um sessionToken novo200 · 544 ms · 245 ms
GET /users/meValida o token, devolve o usuário200 · 861 ms
POST /requestPasswordResetEnvia o e-mail de recuperação200 {} · e-mail 1 s depois
POST /verificationEmailRequestEnvia o e-mail de verificação (quando ligado)200 {} · sem e-mail em conta Free
POST /logoutRevoga o token que chamou200 · 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.

Medido · 17 de setembro de 2026 · navegador → backend Back4app, USA East
597 mscadastro, POST /users
→
245 mslogin, POST /login
1 schegada do e-mail de recuperaçã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:

// 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

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

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

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:

// 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<Map<String, dynamic>> _call(String method, String path, {Map<String, dynamic>? 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 ? <String, dynamic>{} : jsonDecode(r.body) as Map<String, dynamic>;
    if (r.statusCode >= 400) throw AuthError(r.statusCode, data['code'] as int?, data['error']?.toString() ?? r.reasonPhrase ?? '');
    return data;
  }

  Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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

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 [email protected], 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

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

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

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:

// 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

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

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

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ê fezHTTPcodeerror
Senha errada, ou username desconhecido404101Invalid username/password.
Cadastro com um username que já existe400202Account already exists for this username.
Cadastro com um e-mail que já existe400203Account already exists for this email address.
Qualquer chamada com token revogado ou inventado400209Invalid session token
Cadastro que o hook rejeitou (ab)400142username needs at least 3 characters.
whoami sem token400209log 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.

Tagsauthenticationuserssessionspassword-resetflutterjavascriptrest-apiparse-server

Perguntas frequentes

Posso adicionar login e cadastro a um app sem rodar meu próprio servidor de autenticação?

Sim. Um backend gerenciado com uma classe de usuários faz isso por REST: POST /users cria a conta e devolve um session token, POST /login devolve um token novo, GET /users/me o valida, POST /logout o revoga. Medimos o cadastro em 597 ms e o login em 245 ms a partir de um navegador em 17 de setembro de 2026, contra um backend Back4app, sem nenhum código de servidor nosso além de um hook de validação de 17 linhas.

Como funciona o e-mail de recuperação de senha se eu não tenho servidor de e-mail?

Você chama POST /requestPasswordReset com o e-mail do usuário. O backend envia a mensagem de [email protected] com um link de uso único; o link abre uma página hospedada onde o usuário digita a nova senha. No nosso teste o e-mail chegou um segundo depois do pedido. Você não escreve o e-mail, o token nem a página.

Por que POST /verificationEmailRequest devolve 200 mas não envia nada?

E-mails de verificação vêm desligados por padrão. No Back4app você os liga em Notifications → Email → Verification, e essa página fica atrás da validação de um cartão na conta (nenhuma cobrança é informada). Até lá o endpoint aceita a requisição e não envia e-mail. E-mails de recuperação de senha não têm essa trava.

Fazer logout invalida todas as sessões daquele usuário?

Não. Cada login cria sua própria linha de sessão, e POST /logout revoga só o token que fez a chamada. Na nossa medição, o token do cadastro continuou devolvendo 200 em GET /users/me depois que fizemos logout com o token de um login posterior. Para encerrar todas as sessões, apague as linhas do usuário na classe _Session com a master key a partir do Cloud Code.

Onde ficam regras de senha como tamanho mínimo?

A dashboard tem uma seção Password Policy em App Settings → Advanced Options (padrão de validação, idade máxima, histórico, bloqueio de conta), mas no plano Free ela é somente leitura. As regras vão, portanto, para um hook beforeSave na classe _User no Cloud Code, que roda em todo cadastro, seja qual for o cliente que o enviou.

Leitura complementar

Revisado e verificado por Back4app Engineering, Editorial review na Back4app

Testado em 17 de setembro de 2026 — Backend: Back4app, plano Free, USA East · Web: uma página HTML + JavaScript puro · Mobile: Flutter 3 (build web, Chrome) · Terminal: curl no macOS.

Mais da Back4app Engineering

Backend · tutorial

Seu frontend fala direto com o banco de dados. Veja como proteger o backend

Backend · tutorial

Build and Deploy a Node.js REST API in 15 Minutes — Without Owning a Server [Full Code Inside]