Skip to content

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.

Ayhan Sipahi Ayhan Sipahi

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#

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#

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#

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

Related posts