La autenticación es la parte de toda aplicación que los desarrolladores subestiman, construyen demasiado rápido y luego pasan meses parcheando. Lo sabía de antemano. Me dije que lo haría bien a la primera. Aun así me quemé, no catastróficamente, pero de formas que quiero documentar para que otros desarrolladores cometan un poco menos de los mismos errores.
Mi proyecto personal, una app social que estoy construyendo en local con Angular y Flask, soporta cuatro métodos de autenticación: login social con Google, login social con Facebook, login social con Bluesky (que es notablemente diferente de los otros), y magic link sin contraseña por email. Además de la opción de conectar múltiples métodos a la misma cuenta. La arquitectura que maneja todo esto se distribuye entre Firebase Auth en el lado del cliente y una capa de autenticación personalizada en Flask en el backend, con Firebase enviando los emails de magic link. Este es un proyecto personal, no un producto. Corre en mi máquina, nunca fue desplegado, y nada de lo que sigue fue dimensionado para el tráfico de nadie más que el mío.
Este post recorre cómo construí cada pieza, los errores que cometí y las consideraciones de seguridad que me gustaría que estuvieran más destacadas en cada tutorial que leí.
Dos caminos de arquitectura
Para una aplicación que necesita Google, Facebook, magic link por email y tal vez una identidad federada como Bluesky, tienes dos arquitecturas realistas. Cada una es una elección legítima. La que elijas depende de si valoras el tiempo hasta tener login funcionando o evitar la dependencia de un proveedor.
Si no quieres un proveedor en tu camino de autenticación, la vía gratuita es correrlo vos mismo: Keycloak es el default maduro, Authentik es el más liviano y moderno, y ambos son open source y no cuestan nada más allá de la máquina donde los pongas. Algo para revisar antes de comprometerte: el passwordless integrado de Keycloak es WebAuthn, así que los magic links por email necesitan una extensión. Igual elegí Firebase para este proyecto, porque el tiempo hasta tener login funcionando se medía en horas y yo era un solo desarrollador construyendo una primera versión. El resto de este post recorre el camino de Firebase que implementé, y las opciones auto-hospedadas se comparan en detalle al final.
Eligiendo Firebase: los tradeoffs que hice
La pregunta que la mayoría de los desarrolladores hacen es: ¿por qué no implementar los flujos OAuth2 directamente? Para login con Google y Facebook, absolutamente podría. El flujo OAuth2 está bien documentado, y hay buenas librerías de Python para manejarlo. Pero implementar OAuth2 correctamente para múltiples proveedores significa manejar el intercambio de tokens, endpoints de información de usuario específicos de cada proveedor, diferentes formatos de token y diferentes respuestas de error para cada proveedor. También asumes la responsabilidad de manejar de forma segura los parámetros de estado OAuth2 para prevenir ataques CSRF.
Firebase Auth maneja todo eso. El SDK de JavaScript en el lado de Angular gestiona el flujo de redirección o popup de OAuth2, recibe el token del proveedor, lo intercambia con Firebase y devuelve un token ID de Firebase. Mi backend en Flask solo ve tokens ID de Firebase; nunca toco un token OAuth2 crudo. La complejidad específica de cada proveedor queda oculta dentro de Firebase.
La contrapartida es una dependencia de Firebase para la autenticación. Si Firebase Auth tiene una caída, los usuarios no pueden iniciar sesión. Ese es un riesgo real. Pero para un proyecto personal desarrollado por una sola persona en etapas tempranas, el ahorro de tiempo de desarrollo justifica la dependencia. Puedo reemplazar Firebase Auth con una implementación directa más adelante cuando el proyecto justifique la inversión en ingeniería.
El servicio de autenticación en Angular
El servicio de autenticación en Angular envuelve las llamadas al SDK de Firebase y produce un estado de autenticación interno limpio que el resto de la aplicación utiliza. La aplicación Angular nunca habla con Firebase directamente excepto a través de este servicio.
// Angular auth service wrapping Firebase + Flask
@Injectable({ providedIn: 'root' })
export class AuthService {
private auth = getAuth();
private readonly apiService = inject(ApiService);
// Internal state
private _currentUser = signal<AppUser | null>(null);
private _authState = signal<AuthState>('loading');
readonly currentUser = this._currentUser.asReadonly();
readonly authState = this._authState.asReadonly();
readonly isAuthenticated = computed(() => this._currentUser() !== null);
constructor() {
// Listen to Firebase auth state changes
onAuthStateChanged(this.auth, async (firebaseUser) => {
if (!firebaseUser) {
this._currentUser.set(null);
this._authState.set('unauthenticated');
return;
}
try {
const idToken = await firebaseUser.getIdToken();
const appUser = await this.exchangeTokenWithBackend(idToken);
this._currentUser.set(appUser);
this._authState.set('authenticated');
} catch (error) {
await signOut(this.auth);
this._authState.set('error');
}
});
}
async signInWithGoogle(): Promise<void> {
const provider = new GoogleAuthProvider();
await signInWithPopup(this.auth, provider);
// onAuthStateChanged handles the rest
}
async signInWithFacebook(): Promise<void> {
const provider = new FacebookAuthProvider();
await signInWithPopup(this.auth, provider);
}
private async exchangeTokenWithBackend(idToken: string): Promise<AppUser> {
return firstValueFrom(
this.apiService.post<AppUser>('/auth/verify', { id_token: idToken })
);
}
}La capa de verificación de tokens en Flask
Cada solicitud que requiere autenticación pasa por un decorador de Flask que verifica el token ID de Firebase. No almaceno tokens de Firebase. Los verifico al vuelo usando el SDK de administración de Firebase, que comprueba la firma del token contra las claves públicas de Firebase y valida expiración, audiencia e emisor.
import firebase_admin
from firebase_admin import auth as firebase_auth
from functools import wraps
from flask import request, g
from app.models.user import User
from app import db
from sqlalchemy import select
def firebase_auth_required(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return error_response([{'code': 'MISSING_TOKEN', 'message': 'Authorization header required'}], 401)
id_token = auth_header.split('Bearer ')[1]
try:
decoded = firebase_auth.verify_id_token(id_token)
except firebase_auth.ExpiredIdTokenError:
return error_response([{'code': 'TOKEN_EXPIRED', 'message': 'Token has expired'}], 401)
except firebase_auth.InvalidIdTokenError:
return error_response([{'code': 'INVALID_TOKEN', 'message': 'Invalid token'}], 401)
firebase_uid = decoded['uid']
user = db.session.execute(
select(User).where(User.firebase_uid == firebase_uid)
).scalar_one_or_none()
if not user:
return error_response([{'code': 'USER_NOT_FOUND', 'message': 'User not found'}], 404)
g.current_user = user
return f(*args, **kwargs)
return decoratedUn endpoint no puede usar ese decorador: /auth/verify, el que el servicio de Angular llama en cada cambio de estado de autenticación de Firebase. Es el único lugar donde un token de Firebase válido puede llegar legítimamente sin una fila de usuario detrás, así que verifica el token por su cuenta y aprovisiona al usuario local en el primer inicio de sesión, a partir de los claims verificados y nada más. Por eso el decorador puede tratar una fila faltante como un 404 en vez de crearla en silencio: para cuando corre cualquier otro endpoint, la fila ya existe.
# /auth/verify: the only endpoint that runs before a local user row exists
@auth_bp.route('/verify', methods=['POST'])
def verify():
id_token = (request.get_json(silent=True) or {}).get('id_token', '')
try:
decoded = firebase_auth.verify_id_token(id_token)
except firebase_auth.ExpiredIdTokenError:
return error_response([{'code': 'TOKEN_EXPIRED', 'message': 'Token has expired'}], 401)
except firebase_auth.InvalidIdTokenError:
return error_response([{'code': 'INVALID_TOKEN', 'message': 'Invalid token'}], 401)
user = db.session.execute(
select(User).where(User.firebase_uid == decoded['uid'])
).scalar_one_or_none()
if user is None:
# First sign-in. Trust the verified claims only, never the request body
user = User(
firebase_uid=decoded['uid'],
email=decoded.get('email'),
email_verified=decoded.get('email_verified', False),
display_name=decoded.get('name'),
)
db.session.add(user)
# sign_in_provider is what the login page uses to show the last method used
user.last_sign_in_provider = decoded.get('firebase', {}).get('sign_in_provider')
db.session.commit()
return success_response(user_schema.dump(user))Manejar múltiples proveedores para el mismo usuario
Aquí es donde la mayoría de los tutoriales de autenticación se detienen y donde viven la mayoría de los bugs. Un usuario se registra con Google usando su dirección gmail.com. Luego, intenta iniciar sesión con un magic link al mismo email. ¿Son el mismo usuario? Deberían serlo. Pero si ingenuamente creas un nuevo registro de usuario para cada método de autenticación, se convierten en dos cuentas diferentes.
Firebase te da una pieza de esto. Con la opción 'Link accounts that use the same email' (una cuenta por dirección de email), Firebase no crea una segunda cuenta para un email que ya conoce. Igual no fusiona nada por su cuenta: iniciar sesión con otro proveedor para ese email falla con auth/account-exists-with-different-credential, y la app tiene que autenticar al usuario con el método original y después llamar a linkWithCredential. La excepción es Google en su propio dominio de email, que Firebase trata como autoritativo, así que un usuario de gmail.com que entra con Google nunca ve ese error. Habilito la opción para los métodos basados en email, y con ese error manejado, login con Google y magic link sobre el mismo email comparten un Firebase UID.
Pero Facebook es diferente. Facebook no garantiza que la dirección de email que proporciona un usuario sea la misma que su email de Google. Un usuario podría usar diferentes emails en diferentes plataformas sociales. Para Facebook, no puedo vincular cuentas automáticamente por email porque no puedo confiar en que los emails coincidan con la intención del usuario.
Mi solución: después de que un usuario se autentica, le muestro sus proveedores conectados en la configuración de su cuenta y le permito vincular manualmente proveedores adicionales mientras está autenticado. Vincular mientras estás autenticado es seguro: el SDK de Firebase maneja el flujo de vinculación, y mi backend almacena la lista de proveedores vinculados resultante. Los usuarios que quieran fusionar cuentas deben hacerlo explícitamente.
Implementación de magic link
La autenticación sin contraseña con magic link se maneja mediante la autenticación por enlace de email de Firebase. El flujo es: el usuario ingresa su email, Firebase envía un enlace de inicio de sesión a ese email, el usuario hace clic en el enlace, el SDK de Firebase en el lado de Angular intercepta la URL y completa el inicio de sesión, produciendo un token ID de Firebase. El mismo flujo de verificación de tokens que el login social toma el control desde ahí.
Las partes complicadas están en la UX, no en la implementación técnica. Los enlaces de inicio de sesión por email de Firebase expiran seis horas después de enviarse, y el SDK cliente no expone ninguna opción para cambiar eso. Completar el inicio de sesión requiere de nuevo la dirección de email (Firebase la pide para prevenir fijación de sesión), y el flujo estándar la lee del localStorage del navegador donde el usuario la escribió. En mobile, un enlace tocado puede abrirse en un navegador distinto de aquel donde se ingresó el email, y la autenticación falla sin razón evidente. El deep linking a aplicaciones móviles agrega otra capa de complejidad.
Mis soluciones: la ventana de seis horas es fija, así que el trabajo de UX va al camino de falla. Detecto el escenario de navegador cruzado y muestro un mensaje de error específico con instrucciones para copiar y pegar el enlace en su navegador deseado. Todavía no implementé deep linking, así que en mobile el fallback es terminar de iniciar sesión en el navegador.
Login con Bluesky: el AT Protocol es diferente
Bluesky es notable porque no encaja en el molde del social login estándar. El AT Protocol, que subyace a Bluesky, tiene su propio modelo de identidad basado en DIDs (Identificadores Descentralizados) y resolución de handles, y el perfil OAuth que publicó en septiembre de 2024 es OAuth 2.1 con PKCE, PAR y DPoP obligatorios, algo que una configuración genérica de cliente OAuth no cubre de fábrica. Firebase Auth no tiene un proveedor integrado para Bluesky.
Mi implementación: autentico contra la API de Bluesky directamente desde Flask usando el handle de Bluesky del usuario y una contraseña de aplicación (no su contraseña principal; las contraseñas de aplicación de Bluesky son credenciales con alcance limitado). Al autenticar exitosamente con Bluesky, recibo un DID de Bluesky y una sesión. Luego creo o busco un usuario en mi base de datos por ese DID y emito mi propio token de sesión. No pasé por Firebase para Bluesky porque no había un punto de integración natural. Las app passwords son el camino heredado, eso sí: desde esa publicación de septiembre de 2024, Bluesky le pide a los proyectos nuevos que usen OAuth en su lugar, así que tratá mi versión como la rápida, no como la que hay que copiar.
Esto significa que los usuarios autenticados por Bluesky usan un tipo de token diferente al de los usuarios autenticados por Firebase. Mi decorador de autenticación en el backend maneja ambos: primero verifica un token Bearer de Firebase, y si eso falla, verifica mi token de sesión personalizado de Bluesky. Esto es más complejo que una sola ruta de autenticación, pero el AT Protocol de Bluesky es lo suficientemente interesante como plataforma para que quisiera soportarlo a pesar del overhead de integración.
# Bluesky authentication endpoint
from atproto import Client as BlueskyClient
@auth_bp.route('/bluesky', methods=['POST'])
def bluesky_login():
data = request.get_json()
handle = data.get('handle', '').strip()
app_password = data.get('app_password', '')
if not handle or not app_password:
return validation_error('credentials', 'Handle and app password required')
try:
client = BlueskyClient()
profile = client.login(handle, app_password)
did = profile.did
except Exception:
return error_response(
[{'code': 'BLUESKY_AUTH_FAILED', 'message': 'Invalid handle or app password'}],
status=401
)
# Find or create user by Bluesky DID
user = UserService.find_or_create_bluesky_user(
did=did,
handle=handle,
display_name=profile.display_name
)
# Issue internal session token
session_token = SessionTokenService.create(user.id)
return success_response({'session_token': session_token, 'user': user_schema.dump(user)})Consideraciones de seguridad que casi paso por alto
Los tokens de magic link deben ser de un solo uso. Después de que se hace clic en un magic link y la autenticación se completa, ese enlace debe invalidarse. Firebase lo maneja automáticamente: los magic links de Firebase son de un solo uso. Pero si construyes un sistema de magic link personalizado sin Firebase, este es un requisito crítico que es fácil olvidar.
La renovación de tokens debe manejarse de forma transparente. Los tokens ID de Firebase expiran después de una hora. Yo no guardo el token. El interceptor HTTP que lo adjunta a las solicitudes de la API le pide a Firebase el token actual en cada request, y getIdToken() devuelve el token cacheado mientras sigue siendo válido y lo renueva cuando está por expirar.
// HTTP interceptor that always gets a fresh Firebase token
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private auth = getAuth();
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// Only attach auth header to our API requests
if (!req.url.includes('/api') && !req.url.includes('/auth')) {
return next.handle(req);
}
return from(this.getToken()).pipe(
switchMap(token => {
if (!token) return next.handle(req);
const authReq = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
return next.handle(authReq);
})
);
}
private async getToken(): Promise<string | null> {
const user = this.auth.currentUser;
if (!user) return null;
// getIdToken() returns cached token if still valid (default); getIdToken(true) forces refresh
return user.getIdToken(false);
}
}El rate limiting en endpoints de autenticación es innegociable. Los endpoints de magic link, en particular, pueden ser abusados para inundar la bandeja de entrada de un usuario con emails de inicio de sesión si no hay rate limiting. En este proyecto los emails de magic link salen por Firebase, que aplica sus propias cuotas de envío y límites por IP; un sistema de magic link propio necesita sus propios límites por email y por IP. Para endpoints que controlo directamente, como el endpoint de autenticación de Bluesky, implementé rate limiting con Flask-Limiter.
Las lecciones de UX
La parte técnica de la autenticación fue difícil. La UX de la autenticación fue más difícil. Los usuarios no piensan en términos de proveedores de autenticación. Piensan en cómo iniciaron sesión la última vez. Si un usuario se registró con Google y luego intenta usar un magic link con el mismo email, esperan llegar a la misma cuenta. Cuando no lo hacen, piensan que el producto está roto. Diseñar las reglas de vinculación, y encontrar un texto de UI que haga obvia la regla sin un párrafo de explicación, tomó más iteraciones que cualquier pieza técnica que construí.
La otra lección de UX: siempre dile al usuario qué método de autenticación usó cuando regresa. Mi página de login recuerda el último método usado en localStorage y lo muestra de forma prominente. El supuesto para el que diseñé es que nadie recuerda si hizo clic en Google o usó un magic link hace seis semanas. Mostrarlo elimina la frustración de probar tres métodos de autenticación antes de encontrar el correcto.
La autenticación es la primera experiencia que los usuarios tienen con tu producto. Invertir el tiempo en hacerla impecable, tanto técnica como desde la perspectiva de UX, se paga cada vez que un usuario regresa sin fricción.
El camino auto-hospedado en detalle
Firebase Auth fue la elección pragmática aquí porque el tiempo hasta tener login funcionando se medía en horas, no semanas. Es una razón legítima. Pero si quieres auto-hospedar o evitar el vendor lock-in, el stack está lo suficientemente maduro como para que levantar un equivalente funcional tome días, no un proyecto de investigación.
Las opciones que elegiría: Keycloak (maduro, completo, la opción por defecto para OIDC auto-hospedado). Authentik (más liviano, moderno, buena developer experience). Lucia (auth como código dentro de tu propio back end JavaScript; su autor anunció en octubre de 2024 que el paquete quedaría deprecado en marzo de 2025 y seguiría vivo como una guía para escribir ese código vos mismo). Supabase Auth (auto-hospedable si corres Supabase, gestionado si no). Ory (orientado a desarrolladores, más piezas móviles). Los cuatro servicios te dan sesiones y social login con trade-offs operativos diferentes; con Lucia esa parte la escribís vos. El passwordless es donde más difieren: el passwordless integrado de Keycloak es WebAuthn, y los magic links por email necesitan una extensión, así que revisá esa pieza antes de elegir.
Si estás empezando hoy y sabes que quieres auto-hospedado, Keycloak es mi recomendación por defecto para la mayoría de los equipos. Authentik si quieres algo más liviano y moderno. Las guías de Lucia si te sientes cómodo manteniendo el auth como código en vez de un servicio. Entre Keycloak y Authentik los tradeoffs son operativos, no de capacidad: ambos cubren la superficie de OIDC y social login que cubre Firebase, y para magic links por email, revisá qué soporta cada uno antes de elegir.
Referencias
- Firebase Authentication. https://firebase.google.com/docs/auth
- Firebase email link auth. https://firebase.google.com/docs/auth/web/email-link-auth
- Descripción general del AT Protocol. https://atproto.com/guides/overview
- Flask-Limiter. https://flask-limiter.readthedocs.io/en/stable/