Auth0 vs Firebase Auth vs Cognito vs Supabase Auth: Which to Choose
Compare Auth0, Firebase Auth, Supabase Auth, AWS Cognito, and custom JWT: which to default to, how the pricing models differ, and the pitfalls to plan for.
Authentication provider choice sets development velocity, compliance ceiling, and monthly bill for years. Swapping providers later is one of the riskiest migrations a product can run. The default worth starting from is the managed provider already native to your primary platform: Firebase Auth for mobile-first consumer apps, Supabase Auth for PostgreSQL-backed products, Cognito inside AWS-native serverless stacks, and Auth0 when SAML/SSO and compliance artifacts are contract requirements. Custom JWT stays a learning exercise.
A common starting point looks nothing like that: Auth0 for the web app, Firebase Auth for mobile, custom JWT for the API, and three separate user tables. Users register on the web, then hit “user not found” on mobile, and consolidation stops being optional. Six dimensions decide which provider you consolidate on, and cost is only the first of them.
What Decides the Provider#
Each of these can veto a provider on its own, which is why price alone makes a poor tiebreaker. Cost structure covers fixed fees, per-user or per-authentication pricing, and what migrating away costs later. Technical integration covers setup time, coverage across web, mobile, and API, and how far customization goes before the flow needs rebuilding. Enterprise readiness covers SOC 2, GDPR, and HIPAA coverage plus SAML/SSO and MFA. Operational characteristics cover SLA commitments, latency under load, and patch burden. Developer experience covers SDK and documentation quality and onboarding time. Strategic alignment covers how well a provider fits the stack already running and how much vendor dependency the team can tolerate.
Provider Analysis#
Each provider wins a different subset of those dimensions:
Auth0#
Auth0 fits enterprise B2B applications, compliance-regulated industries, and organizations that need extensive SSO integration. It is a poor match for cost-sensitive early-stage products or apps with simple authentication requirements.
Client configuration:
// Auth0 SPA client configuration
const auth0Config = {
domain: process.env.AUTH0_DOMAIN,
clientId: process.env.AUTH0_CLIENT_ID,
audience: process.env.AUTH0_AUDIENCE,
// Critical: Set proper scopes for API access
scope: 'openid profile email read:users write:users',
// Cache tokens properly to avoid rate limits
cacheLocation: 'localstorage',
useRefreshTokens: true,
// Handle token expiration gracefully
onRedirectCallback: (appState) => {
window.history.replaceState(
{},
document.title,
appState?.returnTo || window.location.pathname
);
}
};
Auth0 ships SOC 2, GDPR, and HIPAA compliance out of the box, alongside comprehensive SAML, LDAP, MFA, and SSO capabilities. The admin dashboard handles advanced user management well, and the documentation plus enterprise-grade support back it up.
The cost curve is steep: free up to 25,000 MAU, then B2C Essentials from $35/month at 500 MAU or Professional from $240/month. Self-service B2C plans stop at 50,000 MAU and B2B plans at 20,000 MAU; past those lines you are negotiating an Enterprise contract. Feature richness adds complexity most simple use cases do not need, and extensive customization through Actions increases migration difficulty. The older Rules and Hooks were deprecated in favor of Actions, so legacy integrations need rewriting before they can be ported. Token validation latency can also increase under high concurrent load.
Where teams get caught: Latency on token validation climbs once a client requests a fresh token on every call and starts colliding with Auth0’s tenant rate limits. Cache the token in the SDK and reuse it until expiry; for machine-to-machine credentials, a shared Redis cache does the same job server-side at the cost of one more dependency.
Firebase Auth#
Optimal Use Cases: Mobile-first consumer applications, Google Cloud ecosystem integration, rapid prototyping Avoid When: Multi-tenant B2B requirements, strict enterprise compliance needs, non-Google cloud environments
Production configuration:
// Firebase Auth setup for React Native + Web
import { initializeApp } from 'firebase/app';
import { getAuth, connectAuthEmulator } from 'firebase/auth';
const firebaseConfig = {
apiKey: process.env.FIREBASE_API_KEY,
authDomain: process.env.FIREBASE_AUTH_DOMAIN,
projectId: process.env.FIREBASE_PROJECT_ID,
// Critical: Don't expose these in client-side code
storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.FIREBASE_APP_ID
};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
// Production-ready error handling
auth.onAuthStateChanged((user) => {
if (user) {
// Always verify token on server side
user.getIdToken(true).then((token) => {
// Send to your backend for verification
verifyTokenOnServer(token);
});
}
});
The Spark plan covers 50,000 monthly active users at no charge, and the native iOS/Android SDKs with React Native support make mobile integration straightforward. The connection to Google Cloud services is seamless, and email and social sign-in get wired up in an afternoon, not a sprint.
Limitations:
- Ecosystem Lock-in: Migration away from Google services creates complexity
- Customization Constraints: Less flexible authentication flow customization than Auth0
- Administrative Features: Basic management interface compared to enterprise solutions
- Compliance Gaps: Limited enterprise compliance and audit capabilities, and SAML/OIDC federation drops the free allowance from 50,000 MAU to 50
Cost Analysis: At 50,000 monthly active users Firebase Auth still costs nothing, while Auth0 has left its 25,000 MAU free plan and Cognito Essentials is billing 40,000 MAU. Past 50,000 the meter switches to Google Cloud Identity Platform rates on the Blaze plan.
Supabase Auth#
Optimal Use Cases: PostgreSQL-centric architectures, cost-conscious startups, open-source projects requiring self-hosting options Avoid When: Enterprise compliance mandates, complex multi-tenant architectures, mission-critical production workloads
Production setup:
// Supabase Auth with proper error handling
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
// Production-ready auth hooks
export const useAuth = () => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
setLoading(false);
});
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
setUser(session?.user ?? null);
setLoading(false);
}
);
return () => subscription.unsubscribe();
}, []);
return { user, loading };
};
Strengths:
- Cost Structure: $25/month for up to 100,000 monthly active users
- Open Source: Self-hosting capability with full source code access
- Database Integration: Direct PostgreSQL access for custom authentication logic
- Real-time Features: Built-in WebSocket subscriptions for live updates
The ecosystem is less mature than Auth0 or Firebase, and enterprise compliance and audit capabilities are limited. Support comes from the community, not a dedicated enterprise team, and advanced features require more manual configuration.
Scaling notes: The free plan carries 50,000 MAU, which covers most products until they have revenue. Pro at $25/month includes 100,000 MAU, so the auth bill stays flat through the range where Auth0 hands you to a sales team.
AWS Cognito#
Cognito is the natural fit for AWS-centric architectures, serverless applications, and high-scale cost optimization; it struggles in multi-cloud deployments, rapid prototyping, and teams without AWS expertise.
Infrastructure definition:
// AWS Cognito with CDK
import { UserPool, Mfa, AccountRecovery } from 'aws-cdk-lib/aws-cognito';
import { Duration } from 'aws-cdk-lib';
const userPool = new UserPool(this, 'MyUserPool', {
userPoolName: 'my-app-users',
selfSignUpEnabled: true,
signInAliases: {
email: true,
phone: true,
},
standardAttributes: {
email: {
required: true,
mutable: true,
},
},
passwordPolicy: {
minLength: 8,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
},
accountRecovery: AccountRecovery.EMAIL_ONLY,
// Critical for production: Enable MFA
mfa: Mfa.REQUIRED,
mfaSecondFactor: {
sms: true,
otp: true,
},
// Token configuration
accessTokenValidity: Duration.hours(1),
idTokenValidity: Duration.hours(1),
refreshTokenValidity: Duration.days(30),
});
Three pricing tiers apply since November 2024: Lite and Essentials include 10,000 MAU at no charge, and user pools created after 22 November 2024 get 50,000 on Lite. Native integration with Lambda, API Gateway, and AWS services comes built in, scaling to millions of users happens automatically, and the AWS security infrastructure and compliance certifications carry over for free. The trade-off is a steep learning curve that assumes AWS expertise, a hosted UI basic enough that most teams build a custom frontend, and CloudWatch logging that can overwhelm a team not already fluent in it. None of it runs outside AWS infrastructure.
Cost Analysis: The tier you pick moves the bill by nearly 3x. At 100,000 MAU, Lite bills 90,000 users at $0.0055 (about $495/month) while Essentials bills the same users at $0.015 (about $1,350/month), and Plus carries no free allowance at all. SMS and email delivery are billed separately through SNS and SES, so SMS-based MFA becomes its own line item.
Custom JWT#
Optimal Use Cases: Simple applications, learning projects, situations requiring complete control Avoid When: Production applications, compliance requirements, team projects
Minimal implementation:
// Custom JWT auth with proper security
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
class CustomAuthService {
private readonly JWT_SECRET = process.env.JWT_SECRET!;
private readonly JWT_EXPIRES_IN = '1h';
private readonly REFRESH_TOKEN_EXPIRES_IN = '7d';
async generateTokens(userId: string, email: string) {
const accessToken = jwt.sign(
{ userId, email, type: 'access' },
this.JWT_SECRET,
{ expiresIn: this.JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ userId, type: 'refresh' },
this.JWT_SECRET,
{ expiresIn: this.REFRESH_TOKEN_EXPIRES_IN }
);
// Store refresh token hash in database
const refreshTokenHash = await bcrypt.hash(refreshToken, 12);
await this.storeRefreshToken(userId, refreshTokenHash);
return { accessToken, refreshToken };
}
async verifyToken(token: string) {
try {
const decoded = jwt.verify(token, this.JWT_SECRET) as any;
// Check if token is blacklisted
const isBlacklisted = await this.isTokenBlacklisted(token);
if (isBlacklisted) {
throw new Error('Token is blacklisted');
}
return decoded;
} catch (error) {
throw new Error('Invalid token');
}
}
async refreshAccessToken(refreshToken: string) {
try {
const decoded = jwt.verify(refreshToken, this.JWT_SECRET) as any;
// Verify refresh token exists in database
const isValid = await this.verifyRefreshToken(decoded.userId, refreshToken);
if (!isValid) {
throw new Error('Invalid refresh token');
}
// Generate new access token
const user = await this.getUserById(decoded.userId);
return this.generateTokens(user.id, user.email);
} catch (error) {
throw new Error('Invalid refresh token');
}
}
}
Strengths:
- Complete control: Full customization of auth flows and the freedom to implement any pattern
- Cost: Only infrastructure costs
- Learning: Great for understanding auth concepts
Limitations:
- Security risks: Easy to make security mistakes, and you are responsible for everything that follows
- Compliance: No built-in compliance features
- Time investment: Significant development time required
Detailed Comparison Matrix#
| Feature | Auth0 | Firebase Auth | Supabase Auth | AWS Cognito | Custom JWT |
|---|---|---|---|---|---|
| Setup Time | 2-4 hours | 30 minutes | 1-2 hours | 4-8 hours | 1-2 weeks |
| Cost (100k MAU) | Enterprise quote | Identity Platform rates | $25/month | $495-$1,350/month | Infrastructure only |
| Mobile Support | Excellent | Excellent | Good | Good | Manual |
| Web Support | Excellent | Good | Excellent | Basic | Manual |
| API Support | Excellent | Good | Good | Excellent | Manual |
| Enterprise Features | Excellent | Basic | Limited | Good | Manual |
| Compliance | SOC2, GDPR, HIPAA | Basic | Limited | SOC2, GDPR | Manual |
| Customization | High | Medium | High | Medium | Unlimited |
| Vendor Lock-in | High | High | Medium | High | None |
| Learning Curve | Medium | Low | Medium | High | High |
Matching Scenarios to Providers#
Scenario 1: B2B SaaS with Enterprise Customers#
A B2B SaaS selling to enterprise customers needs SAML/SSO, compliance, user management, and audit logs, which is Auth0’s strongest ground. SAML connections are configured in the Auth0 tenant. What belongs in application code is the per-tenant claim the app reads after login, and that goes in a post-login Action:
// Auth0 post-login Action: attach the enterprise tenant to the ID token
exports.onExecutePostLogin = async (event, api) => {
const tenant = event.user.app_metadata?.enterprise;
if (!tenant) {
return;
}
api.idToken.setCustomClaim('https://myapp.com/enterprise', tenant);
api.accessToken.setCustomClaim('https://myapp.com/enterprise', tenant);
};
Scenario 2: Mobile-First Consumer App#
A consumer app that needs social login and rapid development on a free tier lands on Firebase Auth almost by default.
Social login setup:
// Firebase Auth with social login
import {
signInWithPopup,
GoogleAuthProvider,
FacebookAuthProvider
} from 'firebase/auth';
const googleProvider = new GoogleAuthProvider();
const facebookProvider = new FacebookAuthProvider();
// Configure providers
googleProvider.addScope('email');
googleProvider.addScope('profile');
facebookProvider.addScope('email');
// Social login implementation
const signInWithGoogle = async () => {
try {
const result = await signInWithPopup(auth, googleProvider);
const user = result.user;
// Send token to backend for verification
const token = await user.getIdToken();
await verifyTokenOnBackend(token);
return user;
} catch (error) {
console.error('Google sign-in error:', error);
throw error;
}
};
Scenario 3: Cost-Conscious Startup#
A startup already running PostgreSQL and iterating fast gets Supabase Auth’s direct SQL access to the users table on top of the flat Pro-plan rate.
Signup with custom metadata:
// Supabase Auth with custom user metadata
const { data: { user }, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'securepassword',
options: {
data: {
full_name: 'John Doe',
company: 'Startup Inc',
role: 'admin'
}
}
});
// Direct database queries for custom logic
const { data: users, error } = await supabase
.from('users')
.select('*')
.eq('company_id', companyId)
.order('created_at', { ascending: false });
Scenario 4: AWS-Heavy Architecture#
A serverless stack already built on Lambda and API Gateway gets native token verification and Lite-tier pricing that stays predictable as usage grows.
Token verification in Lambda:
// Cognito with Lambda triggers
import { CognitoJwtVerifier } from 'aws-jwt-verify';
const verifier = CognitoJwtVerifier.create({
userPoolId: process.env.COGNITO_USER_POOL_ID!,
tokenUse: 'access',
clientId: process.env.COGNITO_CLIENT_ID!,
});
// Lambda function with Cognito auth
export const handler = async (event) => {
try {
// API Gateway v2 lowercases header names; v1 preserves the original case
const header = event.headers.authorization ?? event.headers.Authorization;
const token = header?.replace('Bearer ', '');
const payload = await verifier.verify(token);
// User is authenticated, proceed with business logic
const userId = payload.sub;
const result = await processUserRequest(userId, event.body);
return {
statusCode: 200,
body: JSON.stringify(result)
};
} catch (error) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Unauthorized' })
};
}
};
Pricing Models Side by Side#
Every provider bills a different unit, which is why headline comparisons mislead. These are 2025 list prices from the four pricing pages linked in the references.
Auth0#
- Free plan: 25,000 monthly active users
- B2C plans: Essentials from $35/month at 500 MAU; Professional from $240/month
- B2B plans: Essentials from $150/month; Professional from $800/month
- Self-service ceiling: 50,000 MAU for B2C and 20,000 MAU for B2B, after which pricing is quoted per Enterprise contract
Firebase Auth#
- Free tier: 50,000 monthly active users on the Spark plan
- Beyond the free tier: billed at Google Cloud Identity Platform rates on Blaze
- SAML/OIDC federation: 50 MAU free, then Identity Platform rates for every federated user
Supabase Auth#
- Free plan: 50,000 monthly active users
- Pro plan: $25/month including 100,000 MAU
- Beyond the included MAU: $0.00325 per MAU
- Worked example: 150,000 MAU = $25 + (50,000 × $0.00325) = $187.50/month
AWS Cognito#
- Lite: 10,000 MAU free, or 50,000 for user pools created after 22 November 2024, then $0.0055 per MAU up to 100,000
- Essentials: 10,000 MAU free, then $0.015 per MAU
- Plus: no free allowance, $0.020 per MAU
- Federated users: SAML and OIDC identities cost $0.015 per MAU above 50 free on every tier
- Not included: SMS and email delivery, billed through SNS and SES
Migration Strategies#
Provider migrations carry real risk: every user has to come out the other side with working credentials, and a half-finished migration locks people out of their own accounts. Two shapes cover most cases.
Migration from Custom JWT to Auth0#
// Migration script for user data
const migrateUsersToAuth0 = async () => {
const users = await getUsersFromCustomDB();
for (const user of users) {
try {
// Create user in Auth0. `connection` is required: it names the
// database connection the user is created in.
const { data: auth0User } = await auth0Management.users.create({
connection: 'Username-Password-Authentication',
email: user.email,
password: generateTemporaryPassword(),
email_verified: user.emailVerified,
user_metadata: {
migrated_from: 'custom_jwt',
original_user_id: user.id
}
});
// Update local database with Auth0 user ID
await updateUserAuth0Id(user.id, auth0User.user_id);
console.log(`Migrated user: ${user.email}`);
} catch (error) {
console.error(`Failed to migrate user ${user.email}:`, error);
}
}
};
Migration from Firebase to Auth0#
Passwords are the hard part. The loop below moves profiles and claims, but it does not move credentials; for that, export the Firebase password hashes with firebase auth:export and load them through Auth0’s bulk user import, which accepts scrypt hashes. Creating users without credentials forces a password reset on your entire base, which generates a support queue nobody planned for.
// Firebase to Auth0 migration
const migrateFromFirebase = async () => {
const firebaseUsers = await getFirebaseUsers();
for (const firebaseUser of firebaseUsers) {
try {
// Create user in Auth0
const { data: auth0User } = await auth0Management.users.create({
connection: 'Username-Password-Authentication',
email: firebaseUser.email,
email_verified: firebaseUser.emailVerified,
user_metadata: {
firebase_uid: firebaseUser.uid,
migrated_at: new Date().toISOString()
}
});
// Migrate custom claims. The identifier parameter is `id`, not `user_id`.
if (firebaseUser.customClaims) {
await auth0Management.users.update(
{ id: auth0User.user_id },
{ app_metadata: firebaseUser.customClaims }
);
}
} catch (error) {
console.error(`Migration failed for ${firebaseUser.email}:`, error);
}
}
};
Production Security#
Token Storage#
Web and native clients need different answers. Native apps have a platform keystore. Browsers do not, and the common workaround is wrong: document.cookie cannot set an HttpOnly cookie, because the whole point of the flag is that JavaScript is locked out. Only the server can issue one, via Set-Cookie.
// Secure token handling
const secureTokenStorage = {
// Native clients: Keychain on iOS, Keystore on Android
storeMobileTokens: async (accessToken: string, refreshToken: string) => {
await SecureStore.setItemAsync('access_token', accessToken);
await SecureStore.setItemAsync('refresh_token', refreshToken);
},
// Web: the browser never holds the refresh token. The backend exchanges the
// authorization code and returns Set-Cookie with HttpOnly, Secure, SameSite.
exchangeCodeForSession: async (code: string, codeVerifier: string) => {
const response = await fetch('/api/auth/callback', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, codeVerifier })
});
return response.ok;
},
// Rotation runs against the cookie, so no token passes through JavaScript
rotateSession: async () => {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
return response.ok;
}
};
Rate Limiting#
// Rate limiting for auth endpoints
import rateLimit from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 5, // 5 attempts per window
message: 'Too many authentication attempts, please try again later',
standardHeaders: true,
legacyHeaders: false,
// Store rate limit data in Redis for distributed systems
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
prefix: 'auth_rate_limit:'
})
});
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
Performance Optimization#
Machine-to-Machine Token Caching#
This is the cache that fixes the Auth0 rate-limit problem described earlier. It holds client-credentials tokens for backend services only; end-user tokens have no business in a shared cache. Every entry is keyed by issuer, client ID, audience, and the normalized scope set, because several services share this Redis. Keying by audience alone would hand one service a token minted with another client’s privileges. The entry expires slightly before the token does, so a request never goes out carrying a token that has already expired.
// Redis-backed cache for client-credentials tokens
import Redis from 'ioredis';
import { createHash } from 'node:crypto';
interface TokenRequest {
issuer: string;
clientId: string;
audience: string;
scopes: string[];
}
class M2MTokenCache {
private redis: Redis;
private readonly EXPIRY_SKEW_SECONDS = 60;
constructor() {
this.redis = new Redis(process.env.REDIS_URL!);
}
// The key covers issuer, client, audience, and the normalized scope set.
// Hashing avoids a delimiter clash with scope names like `read:users`.
private cacheKey(request: TokenRequest): string {
const scopes = [...new Set(request.scopes)].sort().join(' ');
const fingerprint = createHash('sha256')
.update([request.issuer, request.clientId, request.audience, scopes].join('\n'))
.digest('hex');
return `m2m:${fingerprint}`;
}
async cacheToken(request: TokenRequest, token: string, expiresIn: number): Promise<void> {
const ttl = Math.max(expiresIn - this.EXPIRY_SKEW_SECONDS, 1);
await this.redis.setex(this.cacheKey(request), ttl, token);
}
async getCachedToken(request: TokenRequest): Promise<string | null> {
return await this.redis.get(this.cacheKey(request));
}
async invalidate(request: TokenRequest): Promise<void> {
await this.redis.del(this.cacheKey(request));
}
}
Connection Pooling#
// Database connection pooling for auth
const pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432', 10),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
// Optimize for auth queries
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
// Verify the server certificate in production. Setting rejectUnauthorized
// to false here would undo the point of enabling TLS.
ssl: process.env.NODE_ENV === 'production'
? { rejectUnauthorized: true, ca: process.env.DB_CA_CERT }
: false
});
Where Integrations Break#
Webhook Race#
Symptom: Accounts appear in the Auth0 dashboard, and the application database has no matching row Root cause: The post-registration webhook and the first authenticated request both try to create the local user Fix: Make local user creation idempotent and treat the unique-constraint violation as a successful outcome
// Idempotent user creation
const createUserIfNotExists = async (auth0User: any) => {
const existingUser = await db.user.findUnique({
where: { auth0Id: auth0User.user_id }
});
if (existingUser) {
return existingUser;
}
try {
return await db.user.create({
data: {
auth0Id: auth0User.user_id,
email: auth0User.email,
emailVerified: auth0User.email_verified,
metadata: auth0User.user_metadata
}
});
} catch (error) {
// Handle race condition
if (error.code === 'P2002') {
return await db.user.findUnique({
where: { auth0Id: auth0User.user_id }
});
}
throw error;
}
};
Clock Skew#
A small fraction of API calls fail token validation, and retrying the same request succeeds. The cause is usually clock drift: the verifying server’s clock has moved away from the issuer’s, so nbf or exp gets evaluated against the wrong second. Allowing a bounded clock tolerance during verification and keeping NTP running on every host closes the gap.
// Token validation with clock skew tolerance
const validateToken = async (token: string) => {
try {
const decoded = jwt.verify(token, process.env.AUTH0_PUBLIC_KEY, {
algorithms: ['RS256'],
clockTolerance: 30, // 30 seconds tolerance
issuer: `https://${process.env.AUTH0_DOMAIN}/`,
audience: process.env.AUTH0_AUDIENCE
});
return decoded;
} catch (error) {
console.error('Token validation error:', error);
throw new Error('Invalid token');
}
};
Decision Framework#
Signals That Pick the Provider#
One signal usually dominates. Find yours before comparing feature grids:
- The product is a mobile consumer app and your analytics already sit in Google Cloud → Firebase Auth
- Product data lives in PostgreSQL and the team is comfortable writing SQL → Supabase Auth
- Compute is Lambda behind API Gateway and IAM already gates everything else → Cognito, on the Lite tier until a feature forces Essentials
- A signed contract names SAML, SCIM, or an audit report → Auth0, and budget for the Enterprise conversation above 50,000 MAU
- Two signals fire at once → follow the one attached to your primary platform, because that is the integration you will maintain daily
For Existing Projects#
- Avoid migration unless something forces it: Authentication migrations carry risk out of proportion to their visible scope
- Baseline before you move: Measure current login success rate and latency, or you will have no way to tell whether the new provider is worse
- Run both providers during the cutover: Verify against the new provider first and fall back to the old one, then remove the fallback once the fallback rate reaches zero
- Export credentials alongside profiles: A migration that forces a global password reset has failed, whatever the user table says
Implementation Guardrails#
- Verify tokens on the server: Client-side validation tells you what the client wants you to believe
- Keep refresh tokens out of JavaScript: httpOnly cookies on web, platform keystore on native
- Rate limit login and registration separately: Credential stuffing and enumeration have different shapes
- Cache machine-to-machine tokens only: This is where provider rate limits actually bite, and user tokens have no place in a shared cache
- Budget from measured MAU: Every provider counts monthly active users slightly differently, and federated identities are frequently priced apart from local ones
- Write down the exit path: Document how you would leave a provider before an incident forces you to work it out
Provider pricing changes often. The figures above are 2025 list prices from the pricing pages in the references, and total cost of ownership includes development time and eventual migration on top of the per-MAU line.
The platform-native default holds for as long as one platform dominates the product. Three things should move it, and none of them is a reason to build your own: a contract that names an identity requirement your provider cannot meet, a second platform growing large enough that neither ecosystem is primary, or an MAU curve crossing a self-service ceiling where the Enterprise quote exceeds the cost of migrating.
References#
- RFC 6749: The OAuth 2.0 Authorization Framework (opens in new tab) - The IETF specification defining the OAuth 2.0 authorization framework that all modern auth providers implement.
- OpenID Connect Core 1.0 Specification (opens in new tab) - The official OpenID Foundation specification for OIDC, the identity layer built on top of OAuth 2.0.
- RFC 7519: JSON Web Token (JWT) (opens in new tab) - The IETF RFC defining the JWT structure, claims, and signing mechanisms used by all major auth providers.
- Amazon Cognito User Pools Documentation (opens in new tab) - Official AWS Cognito developer guide covering user pools, identity federation, and OIDC integration.
- Amazon Cognito: Authentication with User Pools (opens in new tab) - Official Cognito guide on authentication flows, including the user pools API and OAuth 2.0 authorization server modes.
- RFC 8725: JSON Web Token Best Current Practices (opens in new tab) - IETF document providing security best practices for implementing and validating JWTs.
- How OpenID Connect Works (opens in new tab) - The OpenID Foundation’s overview of OIDC authentication flows, tokens, and discovery mechanisms.
- Auth0 Pricing (opens in new tab) - Free plan limits, B2C and B2B plan tiers, and the MAU ceilings where self-service ends and Enterprise pricing begins.
- Firebase Pricing (opens in new tab) - Spark and Blaze plan limits for Firebase Authentication, including the separate allowance for SAML and OIDC sign-in.
- Supabase Pricing (opens in new tab) - Free and Pro plan MAU allowances plus the per-MAU rate applied beyond the included quota.
- Amazon Cognito Pricing (opens in new tab) - The Lite, Essentials, and Plus tiers, their free MAU allowances, per-MAU rates, and federated identity pricing.
Related posts
HMAC-SHA-256 for webhooks, signed URLs, and internal auth, with runnable code in three languages and the boundary where digital signatures take over.
security · encryption · webhooks +4
Build SaaS authorization with AWS Cognito and Verified Permissions, covering Cedar policies, multi-tenant patterns, JWT flow, and cost in TypeScript.
authorization · aws · authentication +4
A technical guide to advanced Amazon Cognito: custom auth flows, federation, multi-tenancy, migration strategies, and production-grade security with CDK.
aws · authentication · serverless +6
Build, secure, and deploy custom Model Context Protocol servers for internal systems in TypeScript, with authentication, monitoring, and Kubernetes deployment.
typescript · mcp · nodejs +5
Definitions, implementation context, and defaults for authentication, token, access control, and Zero Trust terminology that teams argue about.
security · authentication · oauth2 +2