# Cómo añadir autenticación a una app web o Flutter sin escribir un servidor de auth

Una página web y una app Flutter obtienen registro, login, logout y recuperación de contraseña con seis llamadas REST a un backend gestionado, sin código que toque una contraseña. Medimos cada llamada, incluido el e-mail que nunca llegó.

URL: https://www.back4app.com/blog/es/como-anadir-registro-login-y-recuperacion-de-contrasena-sin-servidor-de-auth
Publicado: 2026-11-02 · Probado: 2026-09-17
Probado en: Backend: Back4app, plan Free, USA East · Web: una página HTML + JavaScript vanilla · Móvil: Flutter 3 (build web, Chrome) · Terminal: curl en macOS
Publisher: Back4app Engineering

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](https://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: `no-reply@b4a.app`, 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`.

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

![Arquitectura: una página web y una app Flutter hacen las mismas seis llamadas REST al backend de Back4app, que mantiene las clases _User y _Session, ejecuta un hook beforeSave en _User y envía el e-mail de recuperación de contraseña cuyo enlace abre una página alojada para elegir la contraseña](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/architecture-diagram.svg)

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

```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"); }
});
```

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.

![La página web del auth starter antes de cualquier solicitud: tres formularios, Sign up, Log in y Forgot your password, cada uno con su propio botón y una barra de estado vacía encima](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/1-start.jpg)

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.

![La página web justo después del registro: la barra de estado verde dice signed up in 597 ms, y el panel Logged in muestra el JSON del usuario con emailVerified false y los tres botones Refresh, Resend verification e-mail y Log out](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/2-signed-up.jpg)

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.

![La página web tras un login fallido: la barra de estado roja dice 101 Invalid username/password. encima del formulario Log in con el username todavía relleno](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/3-wrong-password.jpg)

## ¿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:

```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
  }
}
```

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.

![La única pantalla de la app Flutter en una ventana de Chrome del tamaño de un teléfono: campos de username, e-mail y contraseña con tres botones, Create account, Log in y Send reset e-mail](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/5-flutter-start.jpg)

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 `no-reply@b4a.app`, 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.

![La página alojada de restablecimiento de contraseña, titulada Reset Your Password for user-auth-starter, con New Password for ana, dos campos de contraseña, un botón Change Password y el logo Powered by Back4App](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/4-choose-password-page.jpg)

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.

![La página Notifications → Email → Verification: un aviso Validate your card con un botón Validate Card, y los conmutadores desactivados Enable Verification y Prevent login with unverified email](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/8-email-verification-settings.jpg)

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

![La página Advanced Options de la dashboard, sección Password Policy: Password Validator Pattern, Validation Error Message, Do Not Allow Username, Max Password Age y Max Password History, todos desactivados en el plan Free](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/10-advanced-options-password-policy.jpg)

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:

```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 };
});
```

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

![El editor de Cloud Code con cloud/main.js abierto en el backend user-auth-starter: el hook beforeSave en Parse.User y la Cloud Function whoami, con el botón Deploy arriba a la derecha](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/7-cloud-code-user-hook.jpg)

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

![La Overview del backend user-auth-starter: el App ID, el desplegable Keys en Javascript Key con su valor difuminado y, a la derecha, Parse Server 7.5.2, MongoDB 3.6 y la URL de la API](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/6-backend-overview-v2.jpg)

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

![El Database Browser mostrando la clase _User con siete objetos: username, createdAt, una columna password que dice hidden en todas las filas y la columna de e-mail](/blog/blog-assets/add-signup-login-password-reset-without-an-auth-server/9-database-user-class.jpg)

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 `sessionToken` en memoria por `flutter_secure_storage` y llama a `me()` 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`/`beforeLogin` y 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 `_Session` de `request.user` con la master key. Doce líneas.
- **Verificación, cuando la tarjeta esté validada.** Activa *Prevent login with unverified email*, observa cómo `POST /login` empieza 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](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to).
