← All posts

When OAuth Login 500s in Prod but Not Locally: The Migration That Never Ran

2026-09-10 · dotnet · ef-core · postgresql · oauth

The report came in from production: every social login attempt — Google, Facebook — returned a 500. Locally, the same flow worked perfectly. The logs made it worse: the real error, a PostgreSQL 42P01: relation "audit_logs" does not exist, was buried under generic “classified” rejection messages, so the 500 looked like an auth failure when it was a missing table.

The setup that caused it

A very common EF Core bootstrap pattern:

if (app.Environment.IsDevelopment())
{
    app.ApplyMigrations(); // ← migrations only run in Development
}

It feels safe. It’s even recommended for local dev loops. But it encodes a hidden assumption: something else applies migrations in production. If nothing does, your production schema silently rots behind your code.

We had shipped new features — social auth with an audit-log write on every login attempt — and the audit table existed in dev databases only. Production never ran UPDATE DATABASE, so the first INSERT INTO audit_logs threw 42P01, the exception bubbled up as a 500, and the auth pipeline classified it as a login rejection.

Why the logs misled us

The auth stack wrapped the exception in its own rejection handling. From the outside it looked like:

POST /api/v1/auth/social/google → 500 Internal Server Error

The 42P01 was several frames down, logged at a level the request pipeline classified as noise. When your error handling classifies failures, make sure schema errors are never classified — they’re always real.

The fix

Two parts:

1. Apply migrations in production, deliberately. Options in rough order of preference:

// At startup, in all environments — with a guard for non-prod databases
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    db.Database.Migrate();
}

or as a release step in CI/CD (my preference for teams: migrations run in the pipeline before the new code receives traffic).

2. Verify with the real flow, not just health checks. After the fix:

$ curl -i -X POST https://api.example.com/api/v1/auth/social/google \
    -H "Content-Type: application/json" -d '{"token":"fake"}'
HTTP/1.1 401 TOKEN_VERIFICATION_FAILED   # ← proper rejection, not 500

A fake token should get a clean 401. Getting a 500 means your auth pipeline is broken somewhere else entirely. After the fix, all 70 tables existed in prod and the endpoint returned proper 401s.

Lessons