Skip to content
Ayhan Sipahi Ayhan Sipahi

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.

The Six Decision Dimensions

Each dimension can veto a provider on its own, which is why price alone makes a poor tiebreaker:

1. Cost Structure Analysis

  • Fixed Costs: Base subscription fees and setup expenses
  • Variable Costs: Per-user, per-authentication, or usage-based pricing
  • Hidden Costs: Development time, maintenance overhead, migration expenses
  • Scale Economics: Cost behavior at 10K, 50K, and 100K+ users

2. Technical Integration Assessment

  • Setup Complexity: Time to production-ready implementation
  • Platform Support: Web, mobile (iOS/Android), API compatibility
  • Customization Depth: Authentication flow modification capabilities
  • Vendor Lock-in Risk: Migration difficulty and data portability

3. Enterprise Readiness

  • Compliance Coverage: SOC 2, GDPR, HIPAA, industry-specific requirements
  • Enterprise Features: SAML/SSO, multi-tenancy, audit logging
  • Security Posture: MFA options, threat detection, security certifications
  • Support Quality: Documentation, community, enterprise support tiers

4. Operational Characteristics

  • Reliability Metrics: SLA commitments, historical uptime
  • Performance Impact: Latency, throughput, caching capabilities
  • Monitoring Integration: Observability, debugging tools
  • Maintenance Burden: Updates, security patches, operational overhead

5. Developer Experience

  • API Quality: SDK completeness, documentation clarity
  • Learning Curve: Onboarding time for development teams
  • Debugging Tools: Error handling, logging, development environments
  • Community Ecosystem: Third-party integrations, community support

6. Strategic Alignment

  • Technology Stack Compatibility: Ecosystem integration benefits
  • Organizational Capability: Required expertise and team skills
  • Growth Trajectory: Scaling characteristics and future requirements
  • Risk Tolerance: Vendor dependency, technical debt implications

Provider Analysis

Each provider wins a different subset of those dimensions:

Auth0: Enterprise-Grade Authentication Platform

Optimal Use Cases: Enterprise B2B applications, compliance-regulated industries, organizations requiring extensive SSO integration Avoid When: Cost-sensitive early-stage applications, 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
    );
  }
};

Strengths:

  • Compliance Foundation: SOC 2, GDPR, HIPAA compliance out of the box
  • Enterprise Features: Comprehensive SAML, LDAP, MFA, and SSO capabilities
  • Management Interface: Robust admin dashboard with advanced user management
  • Support Ecosystem: Extensive documentation and enterprise-grade support

Limitations:

  • Cost Structure: 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
  • Complexity Overhead: Feature richness creates unnecessary complexity for simple use cases
  • Vendor Lock-in: Extensive customization through Actions increases migration difficulty, and the older Rules and Hooks were deprecated in favor of Actions, so legacy integrations need rewriting before they can be ported
  • Performance Variability: Token validation latency can 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: Google-Integrated Mobile-First Solution

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);
    });
  }
});

Strengths:

  • Cost Efficiency: The Spark plan covers 50,000 monthly active users at no charge
  • Mobile Excellence: Native iOS/Android SDKs with React Native support
  • Ecosystem Integration: Seamless connection to Google Cloud services
  • Rapid Deployment: Email and social sign-in 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. Auth is rarely the expensive line either way: the Firestore reads behind a session usually cost more than the sign-in itself.

Supabase Auth: Open-Source PostgreSQL-Native Platform

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

Limitations:

  • Platform Maturity: Less mature ecosystem compared to Auth0/Firebase
  • Enterprise Features: Limited enterprise compliance and audit capabilities
  • Support Model: Community-driven support versus dedicated enterprise support
  • Configuration Complexity: Advanced features require more manual setup

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. Advanced work such as custom JWT claims is code you write yourself.

AWS Cognito: Cloud-Native Identity Management

Optimal Use Cases: AWS-centric architectures, serverless applications, high-scale cost optimization Avoid When: Multi-cloud deployments, rapid prototyping requirements, teams lacking 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),
});

Strengths:

  • Cost Efficiency: Three tiers 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
  • AWS Integration: Native integration with Lambda, API Gateway, and AWS services
  • Infinite Scale: Automatic scaling to millions of users
  • Security Foundation: AWS security infrastructure and compliance certifications

Limitations:

  • Learning Curve: Steep learning curve requiring AWS expertise
  • Ecosystem Lock-in: Difficult to implement outside AWS infrastructure
  • Interface Limitations: Basic hosted UI requiring custom frontend development
  • Operational Complexity: CloudWatch logging and debugging can overwhelm teams

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 Solution: The Full Control Option

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
  • Cost: Only infrastructure costs
  • Learning: Great for understanding auth concepts
  • Flexibility: Can implement any auth pattern

Limitations:

  • Security risks: Easy to make security mistakes
  • Maintenance: You’re responsible for everything
  • Compliance: No built-in compliance features
  • Time investment: Significant development time required

Detailed Comparison Matrix

FeatureAuth0Firebase AuthSupabase AuthAWS CognitoCustom JWT
Setup Time2-4 hours30 minutes1-2 hours4-8 hours1-2 weeks
Cost (100k MAU)Enterprise quoteIdentity Platform rates$25/month$495-$1,350/monthInfrastructure only
Mobile SupportExcellentExcellentGoodGoodManual
Web SupportExcellentGoodExcellentBasicManual
API SupportExcellentGoodGoodExcellentManual
Enterprise FeaturesExcellentBasicLimitedGoodManual
ComplianceSOC2, GDPR, HIPAABasicLimitedSOC2, GDPRManual
CustomizationHighMediumHighMediumUnlimited
Vendor Lock-inHighHighMediumHighNone
Learning CurveMediumLowMediumHighHigh

Matching Scenarios to Providers

Scenario 1: B2B SaaS with Enterprise Customers

Requirements: SAML/SSO, compliance, user management, audit logs Choice: Auth0 Why: Enterprise features, compliance out of the box, excellent admin dashboard

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

Requirements: Social login, push notifications, rapid development Choice: Firebase Auth Why: Excellent mobile integration, free tier, Google ecosystem

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

Requirements: Low cost, PostgreSQL integration, rapid iteration Choice: Supabase Auth Why: 100,000 MAU inside the $25/month Pro plan, plus direct SQL access to the users table

Signup with custom metadata:

// Supabase Auth with custom user metadata
const { data: { user }, error } = await supabase.auth.signUp({
  email: '[email protected]',
  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

Requirements: Serverless, cost optimization, AWS integration Choice: AWS Cognito Why: Seamless Lambda integration, Lite tier pricing that stays predictable at scale

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' })
    };
  }
};

Scenario 5: Learning Project or Simple App

Requirements: Understanding auth concepts, complete control Choice: Custom JWT Solution Why: Educational value, no vendor dependencies

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);
    }
  }
};

Security Considerations: Production Patterns

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 no request travels with a token that dies in flight.

// 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
});

Common Pitfalls

Webhook Race: Users Exist at the Provider but Not in Your Database

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: Intermittent Invalid-Token Errors

Symptom: A small fraction of API calls fail token validation, and retrying the same request succeeds Root cause: The verifying server’s clock drifts from the issuer’s, so nbf or exp is evaluated against the wrong second Fix: Allow a bounded clock tolerance during verification and keep NTP running on every host

// 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

  1. Verify tokens on the server: Client-side validation tells you what the client wants you to believe
  2. Keep refresh tokens out of JavaScript: httpOnly cookies on web, platform keystore on native
  3. Rate limit login and registration separately: Credential stuffing and enumeration have different shapes
  4. Cache machine-to-machine tokens only: This is where provider rate limits actually bite, and user tokens have no place in a shared cache
  5. Budget from measured MAU: Every provider counts monthly active users slightly differently, and federated identities are frequently priced apart from local ones
  6. Write down the exit path: Knowing how you would leave is cheaper than discovering it under pressure

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: 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. None of the three is a reason to build your own.

References

Related posts