Start Debugging

Fix: EF Core MigrateAsync and CanConnectAsync keep retrying on 'Login failed for user' for 60 seconds

EF Core's SQL Server existence check retries error 18456 for a full minute, with or without EnableRetryOnFailure. Fail fast, cap RetryTimeout, or wait for EF Core 12.

If Database.MigrateAsync(), EnsureCreatedAsync() or CanConnectAsync() hangs for about a minute before throwing Login failed for user (or before returning false), the retrying comes from EF Core itself, not from EnableRetryOnFailure. SqlServerDatabaseCreator treats SQL error 18456 as retryable in its existence check and keeps reconnecting every 500 ms until its one-minute RetryTimeout expires. Turning retries off changes nothing. There are three workarounds: open the connection yourself before migrating so a bad password fails on the first attempt, lower RetryTimeout, or give health checks a timeout. The real fix (dotnet/efcore#38927) only ships in EF Core 12. I measured all of this on EF Core 10.0.12 and 11.0.0-rc.1, and they behave identically.

The error in context

The exception is the plain SQL Server login failure. What gives it away is how long it takes to arrive:

Microsoft.Data.SqlClient.SqlException (0x80131904): Login failed for user 'app'.
Error Number:18456,State:1,Class:14

Typical symptoms:

A normal query with the same connection string fails on the first attempt. The slow path is limited to the APIs that ask “does this database exist?”

Why EF Core retries a login failure

CanConnectAsync, MigrateAsync, EnsureCreatedAsync and EnsureDeletedAsync all start by calling IRelationalDatabaseCreator.ExistsAsync(). For SQL Server that is SqlServerDatabaseCreator, and its existence check is a loop of its own:

// EF Core 10.0.12 and 11.0.0-rc.1, SqlServerDatabaseCreator (abridged)
public virtual TimeSpan RetryDelay { get; set; } = TimeSpan.FromMilliseconds(500);
public virtual TimeSpan RetryTimeout { get; set; } = TimeSpan.FromMinutes(1);

// inside ExistsAsync: open the connection, run SELECT 1, and on SqlException:
if (!retryOnNotExists && IsDoesNotExist(e)) // 4060, 1832, 5120
    return false;
if (DateTime.UtcNow > giveUp || !RetryOnExistsFailure(e))
    throw;
await Task.Delay(RetryDelay, ct);

private bool RetryOnExistsFailure(SqlException exception)
    => (exception.Number is 203 && exception.InnerException is Win32Exception)
       || exception.Number is 233 or -2 or 4060 or 1832 or 5120 or 18456;

Error 18456 was added to that list in EF Core 6.0 by dotnet/efcore#25832. It was a workaround for dotnet/efcore#15644: Azure SQL can briefly answer Login failed right after CREATE DATABASE, so EnsureCreated and the first Migrate against a new database failed at random. The workaround was needed only in the post-creation check (CreateAsync calls ExistsAsync(retryOnNotExists: true)), but the same method is used for every existence check. So a password that is simply wrong gets treated as “the database is still warming up” and retried for a full minute. dotnet/efcore#38886, opened on 2026-08-31, reported exactly that.

This also explains why EnableRetryOnFailure looks guilty but isn’t. The loop runs inside a single execution-strategy operation. When the minute is up, the strategy asks SqlServerTransientExceptionDetector.ShouldRetryOn(18456), gets false (18456 is not in that list), and rethrows. With retries on or off, the timing is the same. errorNumbersToAdd doesn’t matter either, unless you add 18456 to it, which would make things worse.

CanConnectAsync then wraps the whole thing in a try/catch that turns any exception except cancellation into false. That’s why the health-check variant never throws: it just takes a minute to say no.

Minimal repro without a SQL Server

You don’t need a server to see this. A DbConnectionInterceptor that throws a SqlException numbered 18456 on every physical open stands in for a server with bad credentials. The SqlException is built by reflection, because its constructors are internal. The probe counts open attempts and times each call:

// .NET 10, EF Core 10.0.12 (also run on .NET 11 RC 1 with EF Core 11.0.0-rc.1.26425.128)
public class FailingOpen(int number) : DbConnectionInterceptor
{
    int _attempts;
    public int Attempts => _attempts;

    public override ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection c, ConnectionEventData e, InterceptionResult r, CancellationToken ct = default)
    {
        Interlocked.Increment(ref _attempts);
        throw FakeSql.Create(number, "Login failed for user 'app'.");
    }
}

public class Shop(FailingOpen interceptor, bool retry) : DbContext
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnConfiguring(DbContextOptionsBuilder o)
        => o.UseSqlServer(
                "Server=db.invalid;Database=Shop;User Id=app;Password=wrong;Encrypt=False",
                sql => { if (retry) sql.EnableRetryOnFailure(); })
            .AddInterceptors(interceptor);
}

Results, identical on EF Core 10.0.12 (SqlClient 6.0) and EF Core 11.0.0-rc.1 (SqlClient 7.0):

CallErrorEnableRetryOnFailureOpen attemptsElapsedOutcome
CanConnectAsync()18456off12160.4 sfalse
CanConnectAsync()18456on12160.2 sfalse
MigrateAsync()18456on12160.2 sSqlException 18456
EnsureCreatedAsync()18456on12160.2 sSqlException 18456
Products.ToListAsync()18456on10.1 sSqlException 18456
CanConnectAsync()4060on10.0 sfalse

The interceptor fails instantly, so 121 attempts is the ceiling: one every 500 ms for 60 seconds. Against a real server every attempt also pays for a TCP connection, TLS and a login round trip, so you’ll see fewer attempts, but the minute is the same. The last row shows the asymmetry: a missing database (4060) short-circuits to false immediately, while a wrong password is the case that retries.

Fix, in detail

In order of preference.

1. Fix the credentials, using the server-side state code

The minute of retries only makes the real problem slower to find. The client always reports State:1. The server writes the real reason to its error log as a state code (in Azure SQL, auditing records it):

StateMeaning
2, 5The login does not exist
6A Windows login name was used with SQL authentication
7The login is disabled (and the password is wrong)
8Wrong password
18The password must be changed
38, 40The login is valid but cannot open the requested database
58SQL authentication used against a server in Windows-only mode

The full list is on the MSSQLSERVER_18456 page. States 38 and 40 are worth knowing, because they look like a credentials problem but are really a permissions or database-name problem. They’re the cousins of the 4060 case covered in the CREATE DATABASE permission denied post.

2. Fail fast before migrating

If you run migrations at startup, open the connection yourself first. OpenConnectionAsync does not go through the existence loop, so a wrong password throws on the first attempt. When the connection is already open, MigrateAsync reuses it:

// .NET 10, EF Core 10.0.12 / 11.0.0-rc.1
static async Task MigrateFailFastAsync(DbContext db, CancellationToken ct = default)
{
    var opened = false;
    try
    {
        await db.Database.OpenConnectionAsync(ct);
        opened = true;
    }
    catch (SqlException ex) when (ex.Number == 4060)
    {
        // Database missing (or no user for this login in it): let MigrateAsync decide.
    }

    try
    {
        await db.Database.MigrateAsync(ct);
    }
    finally
    {
        if (opened) await db.Database.CloseConnectionAsync();
    }
}

The probe measured 1 attempt and 0.0 s to the SqlException 18456, with EnableRetryOnFailure on. The catch for 4060 matters. If your migrations are expected to create the database (local development, a first deployment), the pre-open fails with 4060 because the database isn’t there yet. Swallowing it lets MigrateAsync take the normal create path, including the post-creation retry that Azure SQL actually needs. If your databases are always provisioned separately, drop the catch and let 4060 fail the startup too.

For production pipelines, the better long-term move is to take migrations out of application startup entirely and run a migrations bundle as a deployment step. It hits the same loop, but a pipeline step that fails after a minute is much less painful than a crash-looping pod.

3. Cap RetryTimeout

RetryTimeout and RetryDelay are public settable properties on SqlServerDatabaseCreator, which lives in an .Internal namespace. Using it raises the EF1001 analyzer warning, and its shape can change between releases:

// .NET 10, EF Core 10.0.12 / 11.0.0-rc.1
#pragma warning disable EF1001 // Internal EF Core API usage.
using Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal;
using Microsoft.EntityFrameworkCore.Storage;

var creator = (SqlServerDatabaseCreator)db.GetService<IRelationalDatabaseCreator>();
creator.RetryTimeout = TimeSpan.FromSeconds(5);
await db.Database.MigrateAsync();
#pragma warning restore EF1001

The probe measured 11 attempts and 5.0 s for MigrateAsync with this in place. The same timeout bounds the post-creation check, so on Azure SQL don’t set it to zero if EnsureCreated or Migrate creates the database. A few seconds keeps the #15644 workaround alive and gets rid of the minute. The creator is a scoped service, so set it on each context instance that runs migrations, not once at startup.

4. Give database health checks a timeout

AddDbContextCheck<T>() runs CanConnectAsync by default, and HealthCheckRegistration.Timeout defaults to Timeout.InfiniteTimeSpan. Unlike AddCheck, AddDbContextCheck has no timeout parameter, so do two things: replace the test with one that skips the existence loop, and set the registration’s timeout through HealthCheckServiceOptions:

// .NET 10, ASP.NET Core 10.0, Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore 10.0.12
builder.Services.AddHealthChecks()
    .AddDbContextCheck<Shop>(customTestQuery: async (db, ct) =>
    {
        await db.Database.OpenConnectionAsync(ct);
        await db.Database.CloseConnectionAsync();
        return true;
    });

// The registration is named after the context type unless you pass a name.
builder.Services.Configure<HealthCheckServiceOptions>(o =>
    o.Registrations.Single(r => r.Name == nameof(Shop)).Timeout = TimeSpan.FromSeconds(5));

DbContextHealthCheck catches whatever the test throws and reports Unhealthy with the exception attached, so a wrong password now shows up as Login failed for user 'app'. in the health report instead of a bare failure a minute later. The timeout is the backstop for everything else, such as a server that accepts the TCP connection and never answers. The general setup is covered in adding a health check endpoint to a minimal API.

5. Upgrade when EF Core 12 ships

dotnet/efcore#38927, merged on 2026-09-10 and milestoned 12.0.0, passes retryOnNotExists through to RetryOnExistsFailure so 18456 is retried only after the provider has just created the database:

// EF Core main (12.0), after dotnet/efcore#38927
|| (exception.Number is 233 or -2 or 4060 or 1832 or 5120)
|| (retryOnLoginFailure && exception.Number is 18456))

As of today the change is not on release/10.0 or release/11.0 (both still have the old one-line check), so EF Core 11.0 GA will most likely ship with the one-minute retry. I didn’t run an EF Core 12 daily build. The PR adds sync and async regression tests for both paths, so the workarounds above are what you have on 10 and 11.

Gotchas and lookalikes

A cancellation token changes the outcome, not just the timing. CanConnectAsync(ct) rethrows cancellation, so with a 5-second CancellationTokenSource the probe got a TaskCanceledException after 10 attempts, not false. Code that only checks the boolean needs a catch (OperationCanceledException).

The synchronous path blocks a thread. Database.Migrate() and CanConnect() use Thread.Sleep(RetryDelay) in the same loop, so the minute is spent holding a thread-pool thread. That’s another reason to run migrations outside request-serving code.

Error 4060 is retried by EnableRetryOnFailure, just not here. 4060 (Cannot open database "Shop" requested by the login) is in the transient list. CanConnectAsync returns false for it immediately, but a normal query with the default EnableRetryOnFailure() (6 retries, 30 s maximum delay) made 7 attempts over 57.9 s before throwing RetryLimitExceededException. If a query-time “login failed” takes about a minute, look at the inner exception’s number before blaming the creator loop. And if you’re tuning the strategy anyway, the execution strategy and user transactions post covers the other trap it sets.

Failed-login noise has side effects. Every retry is a real failed login on the server. With CHECK_POLICY = ON, SQL logins follow the Windows account lockout policy, and Azure SQL auditing records each attempt. A minute of retries can lock the account, and after that even the correct password fails, with error 18486 (“the account is currently locked out”) instead of 18456.

Timeouts are a different problem. If the minute ends in Timeout expired rather than Login failed, you’re looking at command or gateway timeouts during a long migration, which is covered in SqlException timeout expired during EF Core migrations.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back