Authentication is the part of every app that developers underestimate, build too quickly, and then spend months patching. I knew this going in. I told myself I would do it right the first time. I still got burned. Not catastrophically, but in ways I want to document so other developers can make slightly fewer of the same mistakes.
My side project, a social app I am building locally with Angular and Flask, supports four authentication methods: Google social login, Facebook social login, Bluesky social login (which is notably different from the others), and passwordless magic link via email. Plus the option to connect multiple methods to the same account. The architecture that handles all of this sits across Firebase Auth on the client side and a custom Flask auth layer on the backend, with Firebase sending the magic link emails. This is a personal side project, not a product. It runs on my machine, it has never been deployed, and none of what follows was sized for anybody's traffic but my own.
This post walks through how I built each piece, the mistakes I made, and the security considerations that I wish were more prominently featured in every tutorial I read.
Two Architecture Paths
For an app that needs Google, Facebook, email magic link, and maybe a federated identity like Bluesky, you have two realistic architectures. Each is a legitimate choice. The one you pick depends on whether you value time-to-working-login or avoiding vendor dependency.
If you do not want a vendor in your auth path, the free route is to run it yourself: Keycloak is the mature default, Authentik is the lighter and more modern one, and both are open source and cost nothing beyond the machine you put them on. One thing to check before you commit: Keycloak's built-in passwordless is WebAuthn, so email magic links need an extension. I picked Firebase for this project anyway, because the time-to-working-login was measured in hours and I was one developer building a first version. The rest of this post walks through the Firebase path I implemented, and the self-hosted options are compared in detail at the end.
Choosing Firebase: The Tradeoffs I Made
The question most developers ask is: why not implement OAuth2 flows directly? For Google and Facebook login, I absolutely could. The OAuth2 flow is well-documented, and there are good Python libraries for handling it. But implementing OAuth2 correctly for multiple providers means handling token exchange, provider-specific user info endpoints, different token formats, and different error responses for each provider. You also take on the responsibility of securely handling OAuth2 state parameters to prevent CSRF attacks.
Firebase Auth handles all of that. The JavaScript SDK on the Angular side manages the OAuth2 redirect or popup flow, receives the provider token, exchanges it with Firebase, and returns a Firebase ID token. My Flask backend only ever sees Firebase ID tokens. I never touch a raw OAuth2 token. The provider-specific complexity is hidden inside Firebase.
The tradeoff is a Firebase dependency for authentication. If Firebase Auth ever has an outage, users cannot log in. That is a real risk. But for a solo side project in early stages, the development time savings are worth the dependency. I can replace Firebase Auth with a direct implementation later when the project justifies the engineering investment.
The Angular Auth Service
The Angular auth service wraps Firebase SDK calls and produces a clean internal auth state that the rest of the application uses. The Angular app never talks to Firebase directly except through this service.
// 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 })
);
}
}The Flask Token Verification Layer
Every request that requires authentication goes through a Flask decorator that verifies the Firebase ID token. I do not store Firebase tokens. I verify them on the fly using the Firebase Admin SDK, which checks the token signature against Firebase's public keys and validates expiry, audience, and issuer.
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 decoratedOne endpoint cannot use that decorator: /auth/verify, the one the Angular service calls on every Firebase auth state change. It is the only place where a valid Firebase token can legitimately arrive with no user row behind it, so it verifies the token itself and provisions the local user on first sign-in, from the verified claims and nothing else. That is why the decorator can treat a missing row as a 404 instead of quietly creating one: by the time any other endpoint runs, the row exists.
# /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))Handling Multiple Providers for the Same User
This is where most auth tutorials stop and where most bugs live. A user signs up with Google using their gmail.com address. Later, they try to log in with a magic link to the same email. Are they the same user? They should be. But if you naively create a new user record for each authentication method, they become two different accounts.
Firebase gives you one piece of this. With the 'Link accounts that use the same email' setting (one account per email address), Firebase will not create a second account for an email it already knows. It does not merge anything on its own, though: signing in with a different provider for that email fails with auth/account-exists-with-different-credential, and the app has to sign the user in with the original method and then call linkWithCredential. The exception is Google on its own email domain, which Firebase treats as authoritative, so a gmail.com user signing in with Google never hits that error. I enable the setting for the email-based methods, and with that error handled, Google login and magic link on the same email share one Firebase UID.
But Facebook is different. Facebook does not guarantee that the email address a user provides is the same as their Google email. A user might use different emails on different social platforms. For Facebook, I cannot automatically link accounts by email because I cannot trust that the emails match the user's intent.
My solution: after a user is authenticated, I show them their connected providers in their account settings and let them manually link additional providers while signed in. Linking while authenticated is safe. Firebase SDK handles the linking flow, and my backend stores the resulting linked provider list. Users who want to merge accounts must explicitly do so.
Magic Link Implementation
Passwordless magic link auth is handled by Firebase's Email Link authentication. The flow is: user enters email, Firebase sends a sign-in link to that email, user clicks the link, Firebase SDK on the Angular side intercepts the URL and completes the sign-in, producing a Firebase ID token. The same token verification flow as social login takes over from there.
The tricky parts are in the UX, not the technical implementation. Firebase email sign-in links expire six hours after they are sent, and the client SDK exposes no setting to change that. Completing the sign-in requires the email address again (Firebase asks for it to prevent session fixation), and the standard flow reads it from the localStorage of the browser where the user typed it. On mobile, a tapped link can open in a different browser than the one where the email was entered, and auth fails with no obvious reason. Deep linking to mobile apps adds another layer of complexity.
My solutions: the six-hour window is fixed, so the UX work goes into the failure path. I detect the cross-browser scenario and show a specific error message with instructions to copy-paste the link into their intended browser. I have not implemented deep linking yet, so on mobile the fallback is to finish signing in in the browser.
Bluesky Login: The AT Protocol Is Different
Bluesky is notable because it does not fit the standard social login mold. The AT Protocol, which underlies Bluesky, has its own identity model based on DIDs (Decentralized Identifiers) and handle resolution, and the OAuth profile it published in September 2024 is OAuth 2.1 with mandatory PKCE, PAR and DPoP, which a generic OAuth client setup does not cover out of the box. Firebase Auth has no built-in Bluesky provider.
My implementation: I authenticate against the Bluesky API directly from Flask using the user's Bluesky handle and app password (not their main password. Bluesky app passwords are scoped credentials). On successful Bluesky auth, I get back a Bluesky DID and session. I then create or look up a user in my database by that DID and issue my own session token. I did not go through Firebase for Bluesky because there was no natural integration point. App passwords are the legacy path, though: since that September 2024 release, Bluesky asks new projects to use OAuth instead, so treat my version as the quick one, not the one to copy.
This means Bluesky-authenticated users use a different token type than Firebase-authenticated users. My backend auth decorator handles both: it first checks for a Firebase Bearer token, and if that fails, checks for my custom Bluesky session token. This is more complex than a single auth path, but Bluesky's AT Protocol is compelling enough as a platform that I wanted to support it despite the integration overhead.
# 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)})Security Considerations I Almost Missed
Magic link tokens must be one-time-use. After a magic link is clicked and auth completes, that link must be invalidated. Firebase handles this automatically. Firebase magic links are single-use. But if you build a custom magic link system without Firebase, this is a critical requirement that is easy to forget.
Token refresh must be handled transparently. Firebase ID tokens expire after one hour. I do not store the token myself. The HTTP interceptor that attaches it to API requests asks Firebase for the current token on every request, and getIdToken() hands back the cached token while it is still valid and refreshes it when it is about to expire.
// 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);
}
}Rate limiting on auth endpoints is non-negotiable. Magic link endpoints, in particular, can be abused to flood a user's inbox with sign-in emails if there is no rate limiting. In this project the magic link emails go out through Firebase, which enforces its own sending quotas and per-IP limits; a custom magic link system needs its own limits per email and per IP. For endpoints I control directly, like the Bluesky auth endpoint, I implemented rate limiting with Flask-Limiter.
The UX Lessons
The technical part of auth was hard. The UX of auth was harder. Users do not think in terms of authentication providers. They think in terms of how they logged in last time. If a user signed up with Google and then tries to use a magic link with the same email, they expect to land in the same account. When they do not, they think the product is broken. Designing the linking rules, and finding UI copy that makes the rule obvious without a paragraph of explanation, took more iteration than any technical piece I built.
The other UX lesson: always tell the user which auth method they used when they return. My login page remembers the last-used method in localStorage and shows it prominently. The assumption I designed for is that nobody remembers whether they clicked Google or used a magic link six weeks ago. Surfacing it removes the frustration of trying three auth methods before finding the right one.
Authentication is the first experience users have with your product. Investing the time to make it airtight, both technically and from a UX perspective, pays back every time a user returns without friction.
The Self-Hosted Path in Detail
Firebase Auth was the pragmatic choice here because the time-to-working-login was measured in hours, not weeks. That is a legitimate reason. But if you want to self-host or avoid the vendor lock-in, the stack is solved enough that standing up a working equivalent takes days, not a research project.
The options I would reach for: Keycloak (mature, feature-complete, the default choice for self-hosted OIDC). Authentik (lighter, modern, good developer experience). Lucia (auth as code inside your own JavaScript back end; its author announced in October 2024 that the package will be deprecated in March 2025 and live on as a guide to writing that code yourself). Supabase Auth (self-hostable if you run Supabase itself, managed if you do not). Ory (developer-first, more moving parts). The four services give you sessions and social login with different operational trade-offs; with Lucia you write that part yourself. Passwordless is where they differ most: Keycloak's built-in passwordless is WebAuthn, and email magic links need an extension, so check that piece before you commit.
If you are starting today and you know you want self-hosted, Keycloak is my default recommendation for most teams. Authentik if you want something lighter and more modern. The Lucia guides if you are comfortable keeping auth as code rather than a service. Between Keycloak and Authentik the tradeoffs are operational, not capability: both cover the OIDC and social login surface that Firebase covers, and for email magic links, check what each one supports before you pick.
References
- Firebase Authentication. https://firebase.google.com/docs/auth
- Firebase email link auth. https://firebase.google.com/docs/auth/web/email-link-auth
- AT Protocol overview. https://atproto.com/guides/overview
- Flask-Limiter. https://flask-limiter.readthedocs.io/en/stable/