↑↓ to move · Enter to open · Esc to close

    All posts
    Article, · 6 min read

    The email that never came

    Building password recovery for RedCheck: user enumeration, email providers that rewrite your links, a VPS that silently blocks SMTP, and a deploy pipeline with a hole in it.

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

    1. POST /auth/forgot-password with 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.
    2. The link opens a dedicated /reset-password page (a real URL, not a modal, because an email link needs somewhere to land).
    3. POST /auth/reset-password with 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 200 with 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.

    RedCheck’s “Forgot your password?” page: a single email field and a “Send reset link” button.
    The forgot-password page. The boring part.

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

    POST /auth/forgot-password

    Single-use token, 30 min
    same 200 response either way

    Background job

    Resend HTTP API
    port 443, not SMTP

    User's inbox
    untouched link

    /reset-password

    POST /auth/forgot-password

    Single-use token, 30 min
    same 200 response either way

    Background job

    Resend HTTP API
    port 443, not SMTP

    User's inbox
    untouched link

    /reset-password

    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:

    1. POST /auth/forgot-password con 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.
    2. 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).
    3. POST /auth/reset-password con 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 200 con 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.

    La página de “¿Has olvidado tu contraseña?” de RedCheck: un único campo de correo y un botón para enviar el enlace.
    La página de recuperar contraseña. La parte aburrida.

    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.

    POST /auth/forgot-password

    Token de un solo uso, 30 min
    misma respuesta 200 siempre

    Tarea en segundo plano

    API HTTP de Resend
    puerto 443, no SMTP

    Bandeja del usuario
    enlace intacto

    /reset-password

    POST /auth/forgot-password

    Token de un solo uso, 30 min
    misma respuesta 200 siempre

    Tarea en segundo plano

    API HTTP de Resend
    puerto 443, no SMTP

    Bandeja del usuario
    enlace intacto

    /reset-password

    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