Toda app con cuentas necesita las mismas cinco cosas: una forma de crear la cuenta, una forma de iniciar sesión, una forma de mantener la sesión abierta, una forma de cerrarla y una forma de volver a entrar cuando la contraseña se ha perdido. Ninguna de ellas es tu producto. Todas son el sitio donde un error te sale más caro.
La respuesta habitual es un servidor de auth: un proceso que escribes y mantienes en marcha, que hashea contraseñas, emite tokens, envía e-mails y rota secretos. Este post va de no escribirlo.
Un backend gestionado ya tiene una clase de usuarios con hashing, sesiones, e-mail y una página alojada para restablecer la contraseña. Tu app habla con él por REST, desde un navegador o desde un teléfono.
Lo construimos dos veces sobre el mismo backend: una página HTML sencilla con 91 líneas de JavaScript y una app Flutter con 58 líneas de Dart para la API. Las dos hacen las mismas seis llamadas. Todo lo que sigue se midió el 17 de septiembre de 2026 en Back4app, incluido el único e-mail que no llegó.
Consigue el código funcionando: el repositorio companion está en github.com/templates-back4app/user-auth-starter — web/, flutter/, cloud/main.js y auth-check.sh, que recorre todo el flujo desde curl e imprime los códigos de estado y los tiempos que verás más abajo.
¿Qué tiene que hacer bien un servidor de auth en realidad?
Más de lo que parece. Esta es la lista que uno escrito a mano tiene que cubrir antes de que sea seguro publicarlo, y dónde cae cada punto cuando lo hace un backend gestionado.
| El trabajo del servidor de auth | Por qué no puedes saltártelo | Quién lo hace aquí |
|---|---|---|
| Hashear las contraseñas, nunca guardarlas | Una tabla filtrada son todas las cuentas | Backend: la columna password muestra (hidden) incluso para ti |
| Emitir un token por login y comprobarlo en cada solicitud | Cookies y JWT son donde viven la mayoría de los bugs | Backend: clase _Session, header X-Parse-Session-Token |
| Revocar un token al cerrar sesión | “Sesión cerrada” tiene que significar eso | Backend: POST /logout |
| Rechazar duplicados y entradas inválidas | Dos cuentas, un e-mail | Backend: códigos 202 / 203, más tu hook |
| Enviar el e-mail de recuperación con un enlace de un solo uso | El enlace es una credencial | Backend: [email protected], página alojada |
| Verificar la dirección de e-mail | O cualquiera puede registrarse como tú | Backend, después de validar una tarjeta |
| Bloquear la fuerza bruta | Adivinar contraseñas a velocidad de red | Ajuste de la dashboard (de pago) o tu hook |
Nada de esa tabla se ejecuta en tu máquina. Lo que sí se ejecuta en tu máquina es un formulario y seis llamadas fetch.
La app nunca ve un hash de contraseña, un secreto de token ni un servidor de correo. Ve JSON.
¿Qué seis llamadas REST sustituyen al servidor de auth?
Las mismas seis desde la página web, desde Flutter y desde curl. Cada llamada envía dos headers que identifican a la app: X-Parse-Application-Id y una clave de cliente (X-Parse-JavaScript-Key en la web, X-Parse-Client-Key en Flutter). Un tercer header, X-Parse-Revocable-Session: 1, pide tokens que POST /logout pueda revocar.
| Llamada | Qué hace | Medido (curl, luego navegador) |
|---|---|---|
POST /users | Crea la cuenta, devuelve sessionToken | 201 · 856 ms · 597 ms |
POST /login | Comprueba la contraseña, devuelve un sessionToken nuevo | 200 · 544 ms · 245 ms |
GET /users/me | Valida el token, devuelve el usuario | 200 · 861 ms |
POST /requestPasswordReset | Envía el e-mail de recuperación | 200 {} · e-mail 1 s después |
POST /verificationEmailRequest | Envía el e-mail de verificación (cuando está activado) | 200 {} · sin e-mail en una cuenta Free |
POST /logout | Revoca el token que hace la llamada | 200 · siguiente GET /users/me → 209 |
Los primeros números de curl incluyen el arranque del backend y el handshake TLS desde una terminal. Los números del navegador son lo que siente un usuario en la segunda solicitud.
Cómo lo medimos: performance.now() alrededor de cada fetch en la página web, y los tiempos que auth-check.sh imprime para curl, una ejecución de cada uno el 17 de septiembre de 2026, desde un portátil al backend de USA East.
Los números de curl incluyen el handshake TLS; los del navegador son una segunda solicitud sobre una conexión ya abierta. El tiempo del e-mail es la hora de recepción en la bandeja de entrada menos la hora de la solicitud.
¿Cómo crea la cuenta, inicia sesión y la cierra la página web?
Un archivo HTML con tres formularios y un panel de sesión iniciada, y un script. El script guarda un único dato de estado, el session token, en sessionStorage: desaparece cuando se cierra la pestaña y no puede leerse desde otro origen. Esta es la parte que importa, el helper api y el handler del registro:
// 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"); }
});
El login es el mismo handler contra /login. El logout llama a /logout con el token y después lo borra, en ese orden: el servidor olvida el token y luego lo olvida la pestaña.
showMe() se ejecuta en cada carga de la página y llama a GET /users/me con el token que haya guardado, así que un token revocado o caducado se detecta en la siguiente visita, en lugar de confiar en él en silencio.

Rellenamos el formulario de registro. La barra de estado informó signed up in 597 ms, y el panel de debajo es la respuesta de GET /users/me: username, e-mail, emailVerified: false y un createdAt de las 15:14:58 UTC.

Después cerramos la sesión y probamos el mismo username con una contraseña incorrecta. El backend responde 101 Invalid username/password. tanto para una contraseña incorrecta como para un username desconocido, que es el comportamiento correcto: el formulario de login no debe decirle a un atacante cuál de las dos mitades estaba mal.

¿Cómo es el mismo flujo en Flutter?
Idéntico, en Dart, con package:http y sin SDK. La clase completa de la API tiene 58 líneas. El método _call es el helper api de la página web; los seis métodos que hay debajo son las seis llamadas:
// 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
}
}
El test/auth_api_test.dart del repositorio no es un test unitario con mocks. Crea un usuario nuevo contra el backend real, comprueba la sesión, cierra sesión, vuelve a iniciarla y verifica que una contraseña incorrecta lanza un AuthError con código 101. flutter test pasó el 17 de septiembre de 2026, que es la única prueba que cuenta.

Una diferencia que conviene reconocer: la página web guarda el token en sessionStorage; la clase de Flutter lo guarda en memoria. En un teléfono quieres que sobreviva a un reinicio, y el sitio para eso es el keychain de la plataforma (flutter_secure_storage), no las shared preferences.
¿Qué llega cuando un usuario pide recuperar la contraseña?
Un e-mail, un segundo después. Pedimos la recuperación para el usuario ana a las 14:57:37 UTC con POST /requestPasswordReset y el mensaje llegó a la bandeja de entrada a las 14:57:38, desde [email protected], con el asunto Password Reset Request for user-auth-starter. El cuerpo es texto plano con un enlace.
El enlace apunta al backend, que redirige (302) a una página alojada bajo /apps/choose_password: un título con el nombre de tu app, el username, dos campos de contraseña y un botón Change Password. El token va en un campo oculto. Esta página no la escribiste tú, y en el plan Free no puedes cambiarle el estilo, pero funciona.

Dos detalles que merece la pena copiar en tu propio formulario. POST /requestPasswordReset devuelve 200 {} exista o no el e-mail, así que tu interfaz debería decir si esa dirección tiene una cuenta, el e-mail de recuperación está en camino, exactamente como hace el repositorio.
Y el enlace de recuperación no inicia sesión a nadie: después de cambiar la contraseña, el usuario vuelve a tu formulario de login, con las sesiones intactas.
¿Por qué no llegó ningún e-mail de verificación?
Porque la verificación está desactivada, y la API no lo dice. Nos registramos con una bandeja de entrada desechable real y después llamamos a POST /verificationEmailRequest con esa dirección. La respuesta fue 200 {}. No llegó nada, ni entonces ni después. El e-mail de recuperación a la misma bandeja tardó un segundo, así que la entrega no era el problema.
El interruptor está en la dashboard, en Notifications → Email → Verification, y en una cuenta Free tiene una segunda puerta: un aviso que dice Validate your card — In order to enable this feature, you must validate your card.
Los dos conmutadores de debajo, Enable Verification y Prevent login with unverified email, están desactivados hasta que lo hagas. La página no indica ningún cargo.

Así que el estado honesto de un backend Free recién creado es: la recuperación de contraseña funciona, la verificación no, y emailVerified se queda en false en todos los usuarios. El mensaje de registro del repositorio dice check your inbox for the verification e-mail; cambia esa línea si publicas antes de validar la tarjeta.
Una vez activada la verificación, la misma llamada a POST /verificationEmailRequest reenvía el e-mail y GET /users/me pasa emailVerified a true después del clic.
¿Dónde van las reglas de contraseña cuando la página de ajustes es de solo lectura?
En un hook. La dashboard tiene una sección Password Policy completa en App Settings → Advanced Options: validez del token de recuperación, una expresión regular de validación, un mensaje de error personalizado, do not allow username in password, antigüedad máxima de la contraseña, historial de contraseñas y una sección Account Lockout con umbral y duración.
En el plan Free todos los campos están desactivados detrás de un aviso: Please upgrade your plan to change custom parse options.

Queda Cloud Code, que se ejecuta en todos los planes. Un beforeSave en la clase _User se ejecuta en cada registro y en cada actualización de perfil, la envíe el cliente que la envíe, así que una regla escrita una vez vale igual para la página web, la app Flutter y un comando curl.
El nuestro exige un e-mail, lo pasa a minúsculas y rechaza los usernames de menos de tres caracteres. Una segunda función, whoami, muestra cómo ve una Cloud Function a quien la llama:
// 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 };
});
Una regla de longitud o complejidad de contraseña va en el mismo hook: request.object.get("password") se puede leer en el beforeSave de un usuario nuevo, antes de que se hashee, y nunca después. Un contador de bloqueo son unas líneas más con un campo failedLogins, que se reinicia en un trigger afterLogin.

¿Qué muestra el backend después de los registros?
Tres pantallas, y cada fila que aparece en ellas la escribió la página web, el test de Flutter o auth-check.sh. No definimos ningún schema a mano.
Overview: de dónde salen las claves. El App ID y, en el desplegable Keys, la JavaScript key para la página web y la Client key para Flutter. Son claves de cliente: dicen qué app está llamando, no quién. Que vayan en una página es lo esperado. La Master key del mismo desplegable es la que nunca debe salir de un servidor.

Database: la clase _User. Siete filas después de nuestras pruebas: web97694 de la página web, fl333903 del test de Flutter, user16949 de curl, ana con la bandeja desechable que recibió el e-mail de recuperación, ab y carla de las comprobaciones del hook, y diana.
La columna password dice (hidden) en todas ellas, también para nosotros; emailVerified está sin definir porque la verificación está desactivada.

La clase _Session de al lado tenía una fila por cada login que hicimos, cada una con su token, su usuario y un expiresAt a un año vista, que es la duración de sesión por defecto que muestra Advanced Options, en solo lectura. Cerrar sesión borra la fila de ese token y ninguna otra. Ese es el hallazgo de la siguiente sección.
¿Qué códigos de error verá tu formulario de login?
El backend responde con un status HTTP y un code numérico en el cuerpo. El formulario debería ramificar por el código; el status es casi siempre 400 o 404 y dice menos. Estos son los que nos encontramos el 17 de septiembre de 2026:
| Qué hiciste | HTTP | code | error |
|---|---|---|---|
| Contraseña incorrecta o username desconocido | 404 | 101 | Invalid username/password. |
| Registro con un username que ya existe | 400 | 202 | Account already exists for this username. |
| Registro con un e-mail que ya existe | 400 | 203 | Account already exists for this email address. |
| Cualquier llamada con un token revocado o inventado | 400 | 209 | Invalid session token |
Registro rechazado por el hook (ab) | 400 | 142 | username needs at least 3 characters. |
whoami sin token | 400 | 209 | log in first. |
Y el que no esperábamos. El script de curl crea una cuenta (token A), inicia sesión (token B), cierra sesión con B y después prueba GET /users/me con los dos. B recibe 209. A sigue recibiendo 200. Logout revoca la sesión que hace la llamada, no al usuario.
Si “cerrar sesión en todas partes” es una funcionalidad que necesitas, una Cloud Function con la master key puede borrar todas las filas de _Session del usuario; la clave de cliente no puede.
¿Cuándo deberías seguir escribiendo tu propio servidor de auth?
Cuando la identidad no es tuya. Si tus usuarios ya existen en un directorio corporativo, el trabajo es federación (SAML, OIDC), y una clase de usuarios no tiene la forma adecuada. Si necesitas hoy la política de contraseñas, el bloqueo de cuentas y las páginas de e-mail personalizadas, aquí cuestan un plan de pago, y es justo comparar ese precio con una semana de tu propio código.
Si necesitas login social, el backend lo tiene en App Settings → Social Auth, pero eso es otro post. Y si tienes una norma de cumplimiento que dice que los hashes de contraseñas deben estar en una base de datos que administras tú, nada de esto aplica.
Para todo lo demás, las seis llamadas de arriba son el servidor de auth, y la parte que te pertenece son 17 líneas de reglas.
¿Qué añadirías al auth starter a continuación?
- Un almacén de tokens de verdad en móvil. Cambia el
sessionTokenen memoria porflutter_secure_storagey llama ame()al arrancar la app, para que una sesión revocada se detecte antes de que se renderice la primera pantalla. - Bloqueo en el hook. Cuenta los logins fallidos en un par
afterLogin/beforeLoginy rechaza después de cinco en diez minutos; en el plan Free es el único sitio donde puede vivir. - Cerrar sesión en todas partes. Una Cloud Function que borre todas las filas de
_Sessionderequest.usercon la master key. Doce líneas. - Verificación, cuando la tarjeta esté validada. Activa Prevent login with unverified email, observa cómo
POST /loginempieza a rechazar a los usuarios sin verificar y gestiona ese código de error en el formulario. - Cierra lo que los usuarios pueden alcanzar. Un usuario con sesión iniciada sigue siendo solo un session token. Qué filas puede leer y escribir es el siguiente post: Tu frontend habla directamente con la base de datos. Así proteges el backend.