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

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.

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.

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.

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.

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.

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.

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

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.

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
sessionTokenem memória porflutter_secure_storagee chameme()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/beforeLogine 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
_Sessionderequest.usercom a master key. Doze linhas. - Verificação, assim que o cartão for validado. Ligue Prevent login with unverified email, veja
POST /logincomeç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.