El correo que no llegaba
Construir la recuperación de contraseña de RedCheck: enumeración de usuarios, proveedores de correo que reescriben tus enlaces, un VPS que bloquea el SMTP en silencio y un pipeline de despliegue con un agujero.
“Forgot your password?” is one of the most boring features in any app. It’s also one users expect without thinking, and RedCheck didn’t have it. If you forgot your password, your account was gone.
I expected to finish it in an afternoon. It took a day, two email providers, a DNS record, a new deployment pipeline and some time on the production server’s command line. This post is the full story, because almost none of the difficulty was in the code.
The flow, and its security details
The design is standard:
POST /auth/forgot-passwordwith an email address. If an account exists, the backend creates a random, single-use token that expires in 30 minutes and emails a link containing it.- The link opens a dedicated
/reset-passwordpage (a real URL, not a modal, because an email link needs somewhere to land). POST /auth/reset-passwordwith the token and the new password.
The details that matter:
- The response is always the same. Whether the email is registered
or not, the endpoint answers
200with the same message, and the frontend shows the same “check your inbox” screen. Otherwise the form becomes a tool for finding out who has an account. - Sending is asynchronous. The email goes out in the background, so a slow or broken email provider never affects the response time, which would also leak whether the account exists.
- Expired and used tokens are cleaned up by an hourly job, the same pattern the recycle bin uses for its own cleanup.
In the same push, logged-in users got a “change password” option in
Settings. Accounts created with Google have no password at all, so for
them it becomes “set a password”. The API also distinguishes “wrong
current password” (400) from “new password is the same as the current
one” (409), so the UI can explain which one happened. Login now accepts
either an email or a username, and a non-existent user produces exactly
the same error as a wrong password.

Provider one: Brevo rewrote my links
The first provider I set up was Brevo, over SMTP. The email arrived. Then I opened the raw message source in Gmail:
- Two tracking pixels had been injected.
- The reset link had been rewritten to go through Brevo’s own redirect domain.
- A
List-Unsubscribeheader had been added, so Gmail treated it as bulk mail.
For a newsletter, that’s a feature. For a password reset, it’s unacceptable: the link a user clicks to take control of their account should go straight to my domain, not through a third party’s tracking redirect. As far as I could find, disabling it isn’t self-service below Brevo’s enterprise tier.
I switched to Resend and checked the raw source again. The message arrived exactly as sent: no pixels, no rewritten links.
The raw source also showed DMARC: FAIL, even though SPF and DKIM both
passed. The domain simply had no DMARC record. Adding one fixed it after
DNS propagation. If you send email from your own domain, check the raw
headers of what you send at least once. The rendered email can look
perfect while the headers tell a different story.
Works locally, does nothing in production
Everything worked end to end on my machine. I deployed it, tried it in production, and no email arrived. No error either.
Problem one: the configuration never reached the server. RedCheck’s
docker-compose.yml lives in its own infrastructure repository (see
part 7). The backend and frontend each have a pipeline that builds an
image and restarts their own container. The infrastructure repository
had no pipeline at all. The compose file on the server was a plain copy
made by hand, so the new email environment variables I had added to the
repository never reached production.
The fix was structural, not a one-off copy: the infrastructure repository
now has its own deploy workflow. Any change to the compose file, the
nginx configuration or the database seed is copied to the server, and
docker compose up -d recreates only the services whose configuration
actually changed.
Problem two: the server can’t send email. With the configuration
finally in place, emails still didn’t arrive, and now the logs showed the
send hanging. From the server itself, I tested the SMTP ports one by
one with openssl s_client and nc. On every port (25, 465, 587, 2465,
2587), the TCP connection succeeded, and then the remote end never said
a word. No SMTP banner, just silence. The VPS provider silently blocks
outbound SMTP, which is common to prevent spam, and it would have broken
Brevo just the same. I had only ever tested SMTP from my laptop.
Asking the provider to unblock it, or trying more ports, would have been fragile. The stack already makes outbound HTTPS calls to Gemini without any problem, so the fix was to stop using SMTP: emails are now sent through Resend’s HTTP API on port 443. The mail dependency and all the SMTP configuration were removed and replaced by a single API key.
I also added an info-level log line before and after each send. The original code only logged failures, which made “the background task never ran” and “it ran and silently succeeded” look exactly the same.
After that, the whole flow worked in production: request, email, link, new password, login.
The hole I didn’t think of
Right after shipping, a question occurred to me: RedCheck’s demo accounts share one public password that every visitor uses. What stopped someone from changing it in Settings, or resetting it, and locking everyone else out?
Nothing. Account deletion was already blocked for the demo accounts,
but the new password endpoints weren’t. It’s fixed now: changing the
password of a demo account returns 403, requesting a reset for one
silently does nothing (so the response still doesn’t reveal anything), and
a reset token belonging to a demo account is rejected anyway, as defence
in depth. The frontend disables the option too, but that’s only
convenience; the backend is the actual enforcement.
The lesson I wrote down: every new endpoint that changes a user must ask whether it applies to shared accounts. The original guard was a single check in one controller, easy to miss when adding a sibling feature.
Sign in with Google: done, and blocked
I also built “Sign in with Google”. The frontend gets an ID token from
Google, and the backend verifies it offline with Google’s public keys,
with no client secret and no redirect flow, then links it to an existing
account by email or creates a new one. It only requests the basic
openid, email and profile scopes, which shouldn’t need Google’s app
verification.
It’s in production, and the button is hidden. Earlier, while scoping Google Calendar sync, I had added Calendar scopes to the same Google Cloud project. Those are sensitive, and verification turns out to be evaluated per project, not per login flow. The whole project was flagged as “requires verification”, every sign-in attempt failed with Google’s generic error page, and it stayed flagged even after I removed the Calendar scopes. The button is behind a flag, so enabling it is a one-line change once Google clears the project.
What I took away
- Password reset is a security feature first: identical responses, short-lived single-use tokens, no third-party link rewriting.
- Read the raw headers of the emails you send.
- “Works locally” says nothing about your production network. Infrastructure has opinions you can’t see from your laptop.
- If part of your system has no deploy pipeline, it isn’t deployed. It’s just a file someone copied once.
- Log successes in background jobs, not only failures.
Try it at redcheckapp.com, ideally with a password you’re happy to forget.
“¿Has olvidado tu contraseña?” es una de las funcionalidades más aburridas de cualquier app. También es una que los usuarios dan por hecha, y RedCheck no la tenía. Si olvidabas tu contraseña, tu cuenta estaba perdida.
Pensaba terminarla en una tarde. Me llevó un día, dos proveedores de correo, un registro DNS, un pipeline de despliegue nuevo y un buen rato en la terminal del servidor de producción. Este post cuenta la historia completa, porque casi nada de la dificultad estaba en el código.
El flujo y sus detalles de seguridad
El diseño es el estándar:
POST /auth/forgot-passwordcon una dirección de correo. Si existe una cuenta, el backend crea un token aleatorio de un solo uso que caduca en 30 minutos y envía por correo un enlace que lo contiene.- El enlace abre una página dedicada,
/reset-password(una URL de verdad, no un modal, porque un enlace de un correo necesita un sitio donde aterrizar). POST /auth/reset-passwordcon el token y la nueva contraseña.
Los detalles que importan:
- La respuesta es siempre la misma. Esté registrado el correo o no,
el endpoint responde
200con el mismo mensaje, y el frontend muestra la misma pantalla de “revisa tu bandeja de entrada”. Si no, el formulario se convierte en una herramienta para averiguar quién tiene cuenta. - El envío es asíncrono. El correo sale en segundo plano, así que un proveedor lento o roto nunca afecta al tiempo de respuesta, que también delataría si la cuenta existe.
- Los tokens caducados y usados se limpian con una tarea cada hora, el mismo patrón que usa la papelera para su propia limpieza.
En la misma tanda, los usuarios con sesión iniciada recibieron la opción
de “cambiar contraseña” en Ajustes. Las cuentas creadas con Google no
tienen contraseña, así que para ellas la opción es “establecer una
contraseña”. La API distingue además “contraseña actual incorrecta”
(400) de “la nueva contraseña es igual que la actual” (409), para que
la interfaz pueda explicar cuál de las dos ha pasado. El login ahora
acepta correo o nombre de usuario, y un usuario que no existe produce
exactamente el mismo error que una contraseña incorrecta.

Primer proveedor: Brevo reescribía mis enlaces
El primer proveedor que configuré fue Brevo, por SMTP. El correo llegó. Luego abrí el código fuente del mensaje en Gmail:
- Se habían inyectado dos píxeles de seguimiento.
- El enlace de restablecimiento se había reescrito para pasar por el dominio de redirección de Brevo.
- Se había añadido una cabecera
List-Unsubscribe, así que Gmail lo trataba como correo masivo.
Para una newsletter, eso es una funcionalidad. Para un restablecimiento de contraseña, es inaceptable: el enlace que pulsa un usuario para recuperar el control de su cuenta debería ir directo a mi dominio, no pasar por la redirección de seguimiento de un tercero. Por lo que pude encontrar, desactivarlo no está al alcance del usuario por debajo del plan enterprise de Brevo.
Me pasé a Resend y volví a revisar el código fuente. El mensaje llegó exactamente como se envió: sin píxeles y sin enlaces reescritos.
El código fuente también mostraba DMARC: FAIL, aunque SPF y DKIM
pasaban los dos. El dominio simplemente no tenía registro DMARC. Añadirlo
lo arregló tras la propagación del DNS. Si envías correo desde tu propio
dominio, revisa al menos una vez las cabeceras en bruto de lo que envías.
El correo puede verse perfecto mientras las cabeceras cuentan otra
historia.
Funciona en local, no hace nada en producción
Todo funcionaba de punta a punta en mi máquina. Lo desplegué, lo probé en producción y no llegó ningún correo. Tampoco ningún error.
Problema uno: la configuración nunca llegó al servidor. El
docker-compose.yml de RedCheck vive en su propio repositorio de
infraestructura (ver parte 7). El backend y el frontend tienen cada uno
un pipeline que construye una imagen y reinicia su propio contenedor. El
repositorio de infraestructura no tenía ningún pipeline. El fichero
compose del servidor era una copia hecha a mano, así que las nuevas
variables de entorno del correo que había añadido al repositorio nunca
llegaron a producción.
La solución fue estructural, no una copia puntual: el repositorio de
infraestructura tiene ahora su propio workflow de despliegue. Cualquier
cambio en el fichero compose, la configuración de nginx o los datos
iniciales de la base de datos se copia al servidor, y
docker compose up -d recrea solo los servicios cuya configuración ha
cambiado de verdad.
Problema dos: el servidor no puede enviar correo. Con la
configuración ya en su sitio, los correos seguían sin llegar, y ahora los
logs mostraban el envío colgado. Desde el propio servidor probé los
puertos SMTP uno a uno con openssl s_client y nc. En todos (25, 465,
587, 2465 y 2587) la conexión TCP se establecía, y después el otro
extremo no decía ni una palabra. Ni banner SMTP, solo silencio. El
proveedor del VPS bloquea en silencio el SMTP saliente, algo habitual
para evitar spam, y habría roto Brevo igual. Yo solo había probado SMTP
desde mi portátil.
Pedirle al proveedor que lo desbloqueara, o probar más puertos, habría sido frágil. El sistema ya hace llamadas HTTPS salientes a Gemini sin ningún problema, así que la solución fue dejar de usar SMTP: los correos se envían ahora a través de la API HTTP de Resend, por el puerto 443. Se eliminaron la dependencia de correo y toda la configuración SMTP, y se sustituyeron por una única clave de API.
También añadí una línea de log de nivel info antes y después de cada envío. El código original solo registraba los fallos, lo que hacía que “la tarea en segundo plano nunca se ejecutó” y “se ejecutó y salió bien en silencio” fueran exactamente iguales.
Después de eso, todo el flujo funcionó en producción: petición, correo, enlace, nueva contraseña, login.
El agujero en el que no había pensado
Justo después de publicarlo se me ocurrió una pregunta: las cuentas demo de RedCheck comparten una contraseña pública que usa todo visitante. ¿Qué impedía que alguien la cambiara en Ajustes, o la restableciera, y dejara fuera a todos los demás?
Nada. Borrar las cuentas demo ya estaba bloqueado, pero los nuevos
endpoints de contraseña no. Ya está arreglado: cambiar la contraseña de
una cuenta demo devuelve 403, pedir un restablecimiento para una no
hace nada en silencio (así la respuesta sigue sin revelar nada) y, aun
así, se rechaza cualquier token que pertenezca a una cuenta demo, como
defensa en profundidad. El frontend también desactiva la opción, pero eso
es solo comodidad; quien la hace cumplir de verdad es el backend.
La lección que apunté: cada endpoint nuevo que modifica un usuario tiene que preguntarse si se aplica a cuentas compartidas. La protección original era una sola comprobación en un controlador, fácil de pasar por alto al añadir una funcionalidad hermana.
Iniciar sesión con Google: hecho, y bloqueado
También construí “Iniciar sesión con Google”. El frontend obtiene un ID
token de Google y el backend lo verifica sin conexión con las claves
públicas de Google, sin client secret y sin flujo de redirección, y
después lo vincula a una cuenta existente por correo o crea una nueva.
Solo pide los scopes básicos openid, email y profile, que no
deberían necesitar la verificación de apps de Google.
Está en producción, y el botón está oculto. Antes, al definir la sincronización con Google Calendar, había añadido scopes de Calendar al mismo proyecto de Google Cloud. Esos sí son sensibles, y resulta que la verificación se evalúa por proyecto, no por flujo de login. Todo el proyecto quedó marcado como “requiere verificación”, cada intento de inicio de sesión fallaba con la página de error genérica de Google, y siguió marcado incluso después de quitar los scopes de Calendar. El botón está detrás de un flag, así que activarlo es un cambio de una línea en cuanto Google libere el proyecto.
Con qué me quedo
- Restablecer la contraseña es ante todo una funcionalidad de seguridad: respuestas idénticas, tokens de un solo uso y corta duración, y nada de terceros reescribiendo enlaces.
- Lee las cabeceras en bruto de los correos que envías.
- “Funciona en local” no dice nada de tu red de producción. La infraestructura tiene opiniones que no ves desde tu portátil.
- Si una parte de tu sistema no tiene pipeline de despliegue, no está desplegada. Es solo un fichero que alguien copió una vez.
- En las tareas en segundo plano, registra también los éxitos, no solo los fallos.
Pruébalo en redcheckapp.com, a ser posible con una contraseña que no te importe olvidar.
- Backend
- DevOps