log in
consulting hosting industries the daily tools about contact

Migrating Off Auth0: When the Bill Outgrows the Value

Auth0 is genuinely great — until your MAU count hits a tier and the invoice makes you do a double-take. Here's how I think about the migration decision.

Auth0 is one of those services I've recommended without hesitation for years — right up until the moment a client forwarded me an invoice and asked me to explain it. The math on Auth0's pricing is fine at small scale and brutal at medium scale. And "medium scale" starts earlier than most people expect.

What Auth0 Actually Buys You

Before I get into the migration case, I want to be honest about what you're paying for, because it's real.

Auth0 handles the stuff that's genuinely hard to get right: secure password storage, MFA flows, social login federation, anomaly detection, bot protection, enterprise SSO via SAML and OIDC, and a hosted UI that works. That last one matters more than people admit — building a login UI that handles every edge case (password managers, mobile keyboards, screen readers, reset flows, locked accounts) takes weeks and never quite feels done.

I've integrated Auth0 for clients in healthcare and e-commerce where the compliance angle matters. HIPAA-eligible configuration, SOC 2 Type II, an audit log you can hand to a security reviewer — that has real value. If you're a two-person startup and someone asks about your auth posture, you can just say "Auth0" and the conversation moves on.

So when I say "the bill outgrows the value," I'm not dismissing what Auth0 gives you. I'm saying there's a point where those benefits don't justify the cost for your specific situation.

Where the Math Breaks

Auth0's free tier is 7,500 MAU. That's enough to validate an idea. Once you're past it, you're on a paid plan, and the per-MAU cost at the Essential tier runs roughly $0.07/MAU depending on how you're billed. That sounds small.

Here's where it stops sounding small: a B2C app with 50,000 MAU is looking at $3,500/month before you add MFA, enterprise connections, or M2M tokens. A healthcare client of mine hit the Enterprise tier trigger almost entirely because of machine-to-machine tokens used for internal service communication — tokens that have nothing to do with "users" in any meaningful sense, but Auth0 counts them.

The other place it breaks is when you're building multi-tenant SaaS. Auth0 Organizations is the answer to that, and it works, but it's priced at the Enterprise tier. I had a biotech client building a portal for research sites — maybe 15 organizations, a few hundred actual humans — and the Auth0 quote for that setup was more than their entire hosting bill. That's the moment you start doing the math on alternatives.

The Migration Decision Framework

I've been through this enough times to have a rough decision tree.

Stay on Auth0 if:

  • You need enterprise SSO (SAML) and your sales cycle depends on it
  • You're in a regulated environment where the compliance documentation Auth0 provides has direct value
  • Your team has zero authentication expertise and no appetite to build it
  • Your MAU count is stable and the bill is a known, budgeted line item

Seriously evaluate alternatives if:

  • Your MAU is growing and you've modeled what Auth0 costs at 2x or 5x your current size
  • You're paying for M2M tokens that are purely internal
  • You're a single-tenant app with no enterprise SSO requirements
  • You're already running Laravel and Postgres — because then the "self-hosted" option is just... your stack

What I Actually Migrate To

I've landed in a few different places depending on the client situation.

Laravel Sanctum + custom auth is the default for apps that are already Laravel and don't need social login or enterprise SSO. It's not glamorous but it's solid, I understand every line of it, and the marginal cost is zero. Session-based auth for SPAs, token-based for APIs.

Laravel Fortify when you want more structure around the registration/login/password reset flow without wiring it yourself. Fortify handles the backend, you own the frontend. Pairs well with Sanctum.

For clients that need social login and some of the Auth0 convenience without the pricing model, I've been looking at WorkOS. It's more expensive per seat at low volume but scales differently and the per-organization model fits multi-tenant SaaS better. Not a cheap option, just a differently-shaped one.

Keycloak for enterprise clients who need SAML federation and are willing to run infrastructure. I've deployed it on a couple of projects. It's powerful, the UI is genuinely ugly, and the learning curve is real — but it's free and it handles things like SCIM provisioning that would cost serious money on Auth0.

What the Migration Actually Looks Like in Laravel

The core of migrating off Auth0 in a Laravel app is replacing the Auth0 SDK middleware with something you control. Here's a simplified version of what that looks like when moving to Sanctum-based token auth for an API:

// Before: Auth0 middleware checking the JWT from Auth0's servers
// Route was protected by \Auth0\Login\Middleware\AuthenticateMiddleware

// After: Sanctum protecting the route
// In routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', function (Request $request) {
        return $request->user();
    });

    Route::get('/profile', [ProfileController::class, 'show']);
});

The harder part is user migration. Auth0 stores your users. You need to get them out and into your own users table without forcing everyone to reset their password.

Auth0 provides a data export extension, but the password hashes use bcrypt with a custom cost factor. The approach I use is a lazy migration:

// In App\Http\Controllers\Auth\LoginController.php
// or wherever you're handling credential verification

public function authenticate(Request $request): RedirectResponse
{
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
    ]);

    $user = User::where('email', $request->email)->first();

    // User exists locally and has a password — normal login
    if ($user && $user->password && Hash::check($request->password, $user->password)) {
        Auth::login($user, $request->boolean('remember'));
        return redirect()->intended('/dashboard');
    }

    // User exists but came from Auth0 migration — no local password yet
    // Attempt to verify against Auth0 Authentication API
    if ($user && ! $user->password) {
        if ($this->verifyAgainstAuth0($request->email, $request->password)) {
            // Store the password locally and mark migration complete
            $user->update([
                'password' => Hash::make($request->password),
                'auth0_migrated_at' => now(),
            ]);
            Auth::login($user, $request->boolean('remember'));
            return redirect()->intended('/dashboard');
        }
    }

    throw ValidationException::withMessages([
        'email' => trans('auth.failed'),
    ]);
}

private function verifyAgainstAuth0(string $email, string $password): bool
{
    try {
        $response = Http::post('https://YOUR_DOMAIN.auth0.com/oauth/token', [
            'grant_type' => 'password',
            'username' => $email,
            'password' => $password,
            'client_id' => config('services.auth0.client_id'),
            'client_secret' => config('services.auth0.client_secret'),
            'scope' => 'openid',
        ]);

        return $response->successful() && isset($response->json()['access_token']);
    } catch (\Exception $e) {
        Log::warning('Auth0 verification failed', ['email' => $email, 'error' => $e->getMessage()]);
        return false;
    }
}

This pattern lets you run both systems in parallel. Users who log in get migrated silently. After 90 days or so, the users who haven't logged in are a small enough tail that you can email them a password reset and call it done.

Note: the Resource Owner Password flow I'm using above is considered legacy by Auth0 and OIDC purists, and it requires a "Regular Web Application" or "Native" client type to have it enabled. Auth0 may deprecate it — verify this is enabled on your tenant before depending on it.

The Gotchas That Will Bite You

Social login users. If someone signed up via Google or GitHub through Auth0, they don't have a password at all — not in Auth0, not anywhere. You'll need to handle these separately, either by adding social login to your new stack (Laravel Socialite is straightforward) or by sending them a set-password email and explaining the migration.

The Auth0 user ID in your database. If you stored auth0|abc123 as the canonical user identifier anywhere in your schema — foreign keys, audit logs, third-party integrations — you have cleanup work. I've seen this spread further than anyone realized. Audit your schema before you start.

M2M token replacement. If you used Auth0 for service-to-service auth, you need a replacement. For internal services I usually move to Laravel Sanctum tokens with specific abilities, stored in the database. Simple, auditable, easy to rotate.

Your logout flow. Auth0's single sign-out hits their /v2/logout endpoint to clear their session. Once you're off Auth0, make sure you're not still pointing any logout flows at their servers.

When I'd Still Reach for Auth0

I'm not anti-Auth0. If a client is closing enterprise deals where the buyer's security team is going to ask about their auth stack, Auth0 or a similar identity provider is an easy answer that saves sales cycles. If you need SAML to connect to Okta or Azure AD for a customer, the Auth0 enterprise connection configuration is genuinely good and the alternative (rolling it yourself with php-saml) is a week of your life you don't get back.

For greenfield projects with modest user counts and no enterprise requirements, though, I don't default to Auth0 anymore. The free tier runs out fast, the next tier jumps hard, and Laravel's built-in auth primitives are mature enough that I'm not giving up much.

The Bottom Line

Auth0 is a good product that prices itself for a customer who's either very small or raising Series B money. If you're in the middle — a real app, real users, watching your AWS and Twilio and Stripe bills — the MAU math will catch up with you.

Migrating off isn't free: there's real engineering work, and you're trading Auth0's compliance paperwork for infrastructure you own. For a lot of the apps I build and maintain, that trade is worth it. Know your numbers before you sign another year.

Need help shipping something like this? Get in touch.