DBA Scripts: Get Failed Login Summary

🔧Part of the DBA-Tools Project, copy/paste SQL Server scripts and health checks.In: Security

Login Failed for User ‘sa’, Hundreds of Times

The error log is full of the same two lines, over and over: Error 18456, State 8, Login failed for user ‘sa’, Password did not match. Sometimes it is sa, sometimes it is an application login, and it is arriving every few seconds. Before you reset anything or call it an attack, you need to know three things: which login, from where, and how often.

Error log, one login failing every five secondsreal example output, the first 3 of 12 failures
2026-10-09 11:38:40.600 Logon Error: 18456, Severity: 14, State: 8. 2026-10-09 11:38:40.600 Logon Login failed for user 'zz_fl_reportsvc'. Reason: Password did not match that for the login provided. [CLIENT: 192.168.1.109] 2026-10-09 11:38:46.090 Logon Error: 18456, Severity: 14, State: 8. 2026-10-09 11:38:46.090 Logon Login failed for user 'zz_fl_reportsvc'. Reason: Password did not match that for the login provided. [CLIENT: 192.168.1.109] 2026-10-09 11:38:51.510 Logon Error: 18456, Severity: 14, State: 8. 2026-10-09 11:38:51.510 Logon Login failed for user 'zz_fl_reportsvc'. Reason: Password did not match that for the login provided. [CLIENT: 192.168.1.109]

Read one failure at a time and it tells you almost nothing. Group them and the cause usually falls out. An internet scan hits sa and other common names from many addresses in bursts. A service or job holding an old password fails from one address, for one login, on a steady beat. A connection string pointing at a dropped database fails with valid credentials, and the reason line says so.

This script reads the current SQL Server error log, groups every login failure by login, client address and reason, and adds the lockout state for each SQL login, so you can tell those apart in one result set.


Why Repeated Failed Logins Matter

  • The client only gets a generic Login failed message. The real reason, and the address it came from, are written to the error log and nowhere else by default
  • A service retrying an old password locks its own login when the password policy is on, and then the correct password stops working too
  • An exposed port with sa enabled is a standing invitation, and the failures in the log are the only record of how often it is being tried
  • Thousands of failures bury every other line in the error log, which makes the next real incident harder to read

When to Run This Script

  • The error log is filling with Error 18456 and you need to know whether it is one source or many
  • An application login keeps getting locked out and nobody knows what is locking it
  • After a password rotation, to find anything still connecting with the old one
  • Routine SQL Server health checks (it is part of the repo’s health check suite)

The Script

Run the following script against your SQL Server instance.

✓ Verified
  • Tested on: SQL Server 2025 (RTM CU8), Windows lab instance
  • Last verified: 2026-10-09 (saved output from a real run after 12 deliberate bad-password logins for a throwaway SQL login, created for the test and dropped after it)
  • Permissions: VIEW SERVER STATE, EXECUTE on xp_readerrorlog, sysadmin to read LOGINPROPERTY for other logins
  • Safety: read-only, impact low
/*
Script Name : Get-FailedLoginSummary
Category    : security
Purpose     : Aggregated failed login analysis from the SQL Server error log and current
              lockout state per SQL login. Surfaces brute-force patterns and locked accounts.
              Complements Get-WeakLoginSettings (which checks policy configuration), this
              checks what is actually happening.
              Note: SQL Server 2025 does not write 18456 events to RING_BUFFER_SECURITY_ERROR;
              xp_readerrorlog is the reliable cross-version source for login failures.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-login-security-audit/)
Requires    : VIEW SERVER STATE, sysadmin (for LOGINPROPERTY on other logins), EXECUTE on xp_readerrorlog
HealthCheck : Yes
Notes       : The reason column is the literal text after "Reason: " in the error log entry.
              Read it before the mapped error_description: every one of these entries is logged
              as 18456 and only the reason separates a bad password from a missing default
              database, which the client sees as 4064.
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;
SET QUOTED_IDENTIFIER ON;

-- INSERT...EXEC cannot be used inside a CTE so materialise to a temp table first
DROP TABLE IF EXISTS #failed_logins;
CREATE TABLE #failed_logins (
    log_date DATETIME,
    process_info NVARCHAR(100),
    log_text NVARCHAR(MAX)
);

-- Filter to current error log (log# 0), type 1 (SQL Server log), login failure messages only
INSERT INTO #failed_logins
EXEC xp_readerrorlog 0, 1, N'Login failed';

WITH parsed AS (
    SELECT
        -- Login name sits between the first pair of single quotes. Some entries carry no
        -- name at all (18452 "untrusted domain" is logged as "Login failed. The login is
        -- from..."), so guard the SUBSTRING or one such line fails the whole run (Msg 537).
        CASE
            WHEN CHARINDEX('''', log_text) > 0
             AND CHARINDEX('''', log_text, CHARINDEX('''', log_text) + 1) > 0
            THEN SUBSTRING(
                log_text,
                CHARINDEX('''', log_text) + 1,
                CHARINDEX('''', log_text, CHARINDEX('''', log_text) + 1) - CHARINDEX('''', log_text) - 1
            )
            ELSE NULL
        END AS login_name,
        -- Client IP/host is in the trailing [CLIENT: ...] tag
        CASE
            WHEN CHARINDEX('[CLIENT: ', log_text) > 0
             AND CHARINDEX(']', log_text, CHARINDEX('[CLIENT: ', log_text)) > 0
            THEN SUBSTRING(
                log_text,
                CHARINDEX('[CLIENT: ', log_text) + 9,
                CHARINDEX(']', log_text, CHARINDEX('[CLIENT: ', log_text))
                    - CHARINDEX('[CLIENT: ', log_text) - 9
            )
            ELSE NULL
        END AS client_host,
        -- The log's own words: everything after "Reason: " up to the [CLIENT: tag
        CASE
            WHEN CHARINDEX('Reason: ', log_text) > 0
            THEN RTRIM(SUBSTRING(
                log_text,
                CHARINDEX('Reason: ', log_text) + 8,
                CASE
                    WHEN CHARINDEX('[CLIENT: ', log_text) > CHARINDEX('Reason: ', log_text)
                    THEN CHARINDEX('[CLIENT: ', log_text) - CHARINDEX('Reason: ', log_text) - 8
                    ELSE 4000
                END
            ))
            ELSE NULL
        END AS reason,
        -- Map reason text to the canonical error code
        CASE
            WHEN log_text LIKE '%untrusted domain%' THEN 18452
            WHEN log_text LIKE '%only administrators%' THEN 18451
            WHEN log_text LIKE '%account is disabled%' THEN 18470
            WHEN log_text LIKE '%password must be changed%' THEN 18488
            WHEN log_text LIKE '%explicitly specified database%' THEN 4060
            WHEN log_text LIKE '%default database%' THEN 4064
            WHEN log_text LIKE '%specified in the login properties%' THEN 4064
            WHEN log_text LIKE '%password did not match%' THEN 18456
            WHEN log_text LIKE '%error occurred while evaluating the password%' THEN 18456
            WHEN log_text LIKE '%could not find a login%' THEN 18456
            ELSE 18456
        END AS error_code,
        log_date
    FROM #failed_logins
),
aggregated AS (
    SELECT
        login_name,
        client_host,
        error_code,
        reason,
        COUNT(*) AS failure_count,
        MIN(log_date) AS first_failure,
        MAX(log_date) AS last_failure
    FROM parsed
    GROUP BY login_name, client_host, error_code, reason
)
SELECT
    agg.login_name,
    agg.client_host,
    agg.error_code,
    CASE agg.error_code
        WHEN 18456 THEN 'Login failed (bad password or login does not exist)'
        WHEN 18452 THEN 'Login from untrusted domain or cannot use Windows auth'
        WHEN 18451 THEN 'Login failed, only admin connections are allowed'
        WHEN 18470 THEN 'Account is disabled'
        WHEN 18488 THEN 'Password must be changed'
        WHEN 4060  THEN 'Credentials are valid, the requested database could not be opened'
        WHEN 4064  THEN 'Credentials are valid, the default database is not usable'
        WHEN 4818  THEN 'Password does not meet complexity requirements'
        ELSE 'Error ' + CAST(agg.error_code AS VARCHAR(10))
    END AS error_description,
    agg.reason,
    agg.failure_count,
    agg.first_failure AS first_failure_approx,
    agg.last_failure AS last_failure_approx,
    CASE
        WHEN sl.name IS NOT NULL
        THEN CAST(LOGINPROPERTY(sl.name, 'IsLocked') AS BIT)
        ELSE NULL
    END AS is_currently_locked,
    CASE
        WHEN sl.name IS NOT NULL
        THEN CAST(LOGINPROPERTY(sl.name, 'BadPasswordCount') AS INT)
        ELSE NULL
    END AS bad_password_count,
    CASE
        WHEN agg.error_code IN (4060, 4064)
        THEN 'CONFIG - valid credentials, database not usable (' + CAST(agg.failure_count AS VARCHAR) +
             ' in log); fix the database or the default database, not the password'
        WHEN agg.failure_count >= 50
        THEN 'CRITICAL - ' + CAST(agg.failure_count AS VARCHAR) +
             ' failures in error log; likely brute-force or application misconfiguration'
        WHEN agg.failure_count >= 10
        THEN 'WARN - repeated failures for login [' + ISNULL(agg.login_name, '(unknown)') + ']'
        ELSE 'INFO'
    END AS status
FROM aggregated AS agg
LEFT JOIN sys.sql_logins AS sl ON sl.name = agg.login_name
ORDER BY agg.failure_count DESC;

DROP TABLE #failed_logins;

It reads log 0, the current error log, filtered to lines containing “Login failed”. The login name, the [CLIENT: ...] address and the text after Reason: are cut out of each line, the reason is mapped to the error the client actually saw, and the lockout state comes from LOGINPROPERTY for any row that matches a SQL login.


How To Run From The Repo

Clone DBA Tools, initialize and run the script:

# Clone dba-tools repo:
git clone https://github.com/peterwhyte-lgtm/dba-tools

# Initialize environment:
cd dba-tools
.\Initialize-Environment.ps1

# Summarise failed logins from the current error log:
.\run.ps1 Get-FailedLoginSummary

# To run against a remote sql server:
.\run.ps1 Get-FailedLoginSummary -ServerInstance SQLSERVER01

This script lives in the repo at:


Example Output

Real example output from a lab instance, run straight after a throwaway SQL login with the password policy on was sent the wrong password 12 times, about five seconds apart, from one address. The rest of the rows are what the lab’s own error log already held. All 15 rows are shown; error_description, reason and status are left out for width and quoted below.

Get-FailedLoginSummary on the lab instancereal example output, not something to copy
login_name client_host error_code failure_count first_failure_approx last_failure_approx is_currently_locked bad_password_count ------------------------- ------------- ---------- ------------- ----------------------- ----------------------- ------------------- ------------------ NT SERVICE\SQLSERVERAGENT 192.168.1.109 4060 16 2026-10-04 18:30:09.610 2026-10-04 22:15:00.990 NULL NULL zz_fl_reportsvc 192.168.1.109 18456 12 2026-10-09 11:38:40.600 2026-10-09 11:39:40.100 1 10 Lab1005_gls_rmt 192.168.1.109 18456 3 2026-10-05 17:59:19.820 2026-10-05 18:03:33.430 NULL NULL Lab1004_gls_sql 192.168.1.109 18456 2 2026-10-04 19:10:44.780 2026-10-04 19:11:00.040 NULL NULL NULL 127.0.0.1 18452 2 2026-10-06 09:04:48.590 2026-10-06 09:06:12.040 NULL NULL NULL 172.31.16.1 18452 1 2026-10-06 09:04:49.230 2026-10-06 09:04:49.230 NULL NULL NULL 192.168.1.109 18452 1 2026-10-06 09:04:47.840 2026-10-06 09:04:47.840 NULL NULL HPAI01\Peter 192.168.1.109 4060 1 2026-10-05 18:24:33.450 2026-10-05 18:24:33.450 NULL NULL HPAI01\Peter 192.168.1.109 4060 1 2026-10-09 11:38:10.550 2026-10-09 11:38:10.550 NULL NULL HPAI01\Peter 192.168.1.109 4060 1 2026-10-09 11:37:14.020 2026-10-09 11:37:14.020 NULL NULL Lab1004_gls_dis 192.168.1.109 18470 1 2026-10-04 19:10:44.540 2026-10-04 19:10:44.540 NULL NULL Lab1004_gls_nodb 192.168.1.109 18456 1 2026-10-04 19:11:00.280 2026-10-04 19:11:00.280 NULL NULL Lab1005_cmd_login 192.168.1.109 18456 1 2026-10-05 18:24:33.090 2026-10-05 18:24:33.090 NULL NULL Lab1005_fou_app 192.168.1.109 4060 1 2026-10-05 19:32:05.410 2026-10-05 19:32:05.410 NULL NULL Lab1005_mig_nodb 192.168.1.109 4064 1 2026-10-05 17:48:25.990 2026-10-05 17:48:25.990 NULL NULL

The second row is the test. 12 failures, one login, one address, 5 to 6 seconds apart, and the login is now locked: is_currently_locked is 1. Its reason reads Password did not match that for the login provided. and its status WARN - repeated failures for login [zz_fl_reportsvc]. That is the shape of a service or a scheduled task holding an old password, not a person mistyping one.

Two details in that row are worth knowing before you trust it. bad_password_count reads 10, not 12: the lab’s Windows lockout threshold is 10, the login locked on the tenth failure, and the counter stopped there. And the two failures after the lock were written to the error log exactly like the ten before it, State 8, Password did not match. In the lab, the error log never said the account was locked. For those failures, only is_currently_locked did.

The top row is a different problem with the same symptom. NT SERVICE\SQLSERVERAGENT failed 16 times between 18:30 and 22:15, every 15 minutes, with the reason Failed to open the explicitly specified database 'WatchtowerMetrics'. The credentials were fine; the database it was asking for was gone. Its status says so: CONFIG - valid credentials, database not usable (16 in log); fix the database or the default database, not the password. Resetting a password there fixes nothing.

The three rows with a NULL login are Error 18452, a Windows login from an untrusted domain. That line carries no login name at all, which is why login_name is empty for it.


Attack, Stale Password or Missing Database

Read the grouped rows against these patterns. The first column is what you see in the output, the last is the next move.

What the rows showWhat it usually isNext move
Many client_host values, often public addresses, sa plus names that do not exist (reason Could not find a login matching the name provided.), arriving in burstsA scan against an exposed portClose the port to the internet at the firewall. Then disable sa once a named sysadmin exists
One client_host, one login, State 8, a steady intervalA service, job, scheduled task or connection pool with an old passwordFind the process on that host and fix its password before you unlock or reset the login
Valid login, error_code 4060 or 4064, a regular intervalA job or connection string pointing at a database that was renamed, dropped or taken offlineFix the database name or the default database. The password is not the problem
client_host is <local machine> or the server’s own addressSomething running on the SQL Server box itself: Agent, a monitoring agent, a scheduled taskLook at Agent jobs, services and scheduled tasks on that server first
is_currently_locked is 1Lockout policy has tripped, whatever the causeUnlock only after the source is fixed, or it locks again within a minute

A single internal address can still be hostile, and a scan can come through one NAT address. The pattern is where you start, not a verdict. If the address alone does not tell you which machine or application it is, the Login Activity Session captures logins with the host name and application name behind them.


Understanding the Results

login_name
client_host
Who tried, and from where, as written in the error log line. client_host is whatever SQL Server saw: an IP address over TCP, <local machine> for a connection from the server itself. A load balancer, proxy or NAT gateway shows up as its own address, not the client behind it. login_name is NULL for lines that carry no name, which is Error 18452.Act when one login appears from many addresses. That is the row that separates a scan from a misconfigured service.
error_code
error_description
reason
Every one of these lines is logged as 18456. error_code maps the reason to what the client actually saw: 18456 for a bad password or a login that does not exist, 4060 when the requested database could not be opened, 4064 when the default database is the problem, 18452, 18470 and 18488 for untrusted domain, disabled and must-change. reason is the literal text from the log.Act when the codes and the reason disagree with what you assumed. Read reason first: it is the log’s own words, and the mapping is only as good as the text it matches.
failure_count
first_failure_approx
last_failure_approx
How many times, and over what window, in the current error log only. A restart or sp_cycle_errorlog starts a new log, so the count resets with it. Divide the window by the count for a rough interval: a round number like 15 minutes is a schedule.
is_currently_locked
bad_password_count
The lockout state right now, from LOGINPROPERTY, for SQL logins only. NULL for Windows logins, for names that do not exist, and for a login that has been dropped since. Lockout only applies to SQL logins with CHECK_POLICY = ON, and it follows the Windows account lockout policy of the server.Act when it is 1. The correct password is refused until the lockout duration passes or someone unlocks it, so a stale password on one box becomes an outage for every client using that login.
status
CRITICAL at 50 or more failures, WARN at 10 or more, CONFIG for any 4060 or 4064 row regardless of count, INFO otherwise. The 10 and 50 thresholds are operational heuristics, not SQL Server limits.

If the script returns nothing while people are reporting failed logins, check two things. Login auditing may be set to none, in which case nothing is written: AuditLevel 2 (failed logins only) is the default, and it was 2 on the lab. Or the connection is failing before authentication, which logs no 18456 at all; that is the pre-login handshake error.

-- 0 none, 1 successful only, 2 failed only (default), 3 both
DECLARE @AuditLevel int;
EXEC master.dbo.xp_instance_regread
     N'HKEY_LOCAL_MACHINE',
     N'Software\Microsoft\MSSQLServer\MSSQLServer',
     N'AuditLevel',
     @AuditLevel OUTPUT;
SELECT @AuditLevel AS AuditLevel;

How to Stop the Failures

Fix the source first, then the login. In the lab the login went from working to locked in under 50 seconds of retries, so unlocking it before the source is fixed just buys you another minute.

-- Stale password: once the service, job or connection string has the new password,
-- unlock the login. UNLOCK needs a password, so this is also the reset.
ALTER LOGIN [AppServiceLogin] WITH PASSWORD = N'ReplaceThisPassword!' UNLOCK;

-- Scan against sa: confirm a named sysadmin login exists and works first,
-- then take sa out of play.
ALTER LOGIN [sa] DISABLE;
  • Stale password. The client_host names the box. On it, look at services running as that login’s application, Agent jobs and SSIS packages, scheduled tasks, linked servers and connection strings in config files. Change the password there, then unlock.
  • A scan. The fix is network first. SQL Server should not be reachable from the internet on its port (see SQL Server Default Ports). Disabling sa turns every guess into a guaranteed failure; check Sysadmin Members before you do it so you are not removing the only way in.
  • Lockout as a weapon. If a login with CHECK_POLICY = ON is reachable by an attacker, they can lock it on purpose. An exposed port turns lockout policy into a denial of service against your own application, which is one more reason to close the port rather than rely on lockout.
  • Missing database. For 4060 and 4064 rows, the fix is the database name in the job or connection string, or the login’s default database: Set the Default Database for a Login.

For what each 18456 state means, and the login checks for State 8 in particular, see Troubleshoot “Login Failed for User” (Error 18456).


MS Docs covers MSSQLSERVER_18456 and its states, LOGINPROPERTY, password policy and lockout, ALTER LOGIN including UNLOCK, and login auditing in full.

Related Scripts

You may also find these scripts useful:


Frequently Asked Questions

Is it an attack if all the failures are for sa?

Not necessarily. Look at client_host. Many addresses, especially public ones, failing for sa and for names that do not exist is a scan. One internal address failing for sa on a regular interval is usually an old tool, script or application that was set up with the sa password years ago and never changed.

Why does client_host say <local machine>?

The connection came from the SQL Server machine itself, over shared memory. Something on that box is trying to log in: an Agent job, a monitoring agent, a scheduled task or a service. Start there rather than on the network.

The login is locked but the error log still says Password did not match. Why?

Because that is what SQL Server writes. In the lab, the failures after the lockout were logged as State 8, Password did not match, exactly like the ones before it. The log did not record the lock against those failures, so check is_currently_locked, or LOGINPROPERTY(name, 'IsLocked') directly.

Why is is_currently_locked NULL for some rows?

Lockout state only exists for SQL logins. Windows logins are locked in Active Directory or on the local machine, not in SQL Server, and a name that does not exist as a login, or one that has since been dropped, has nothing to look up.

Where are the failures from before the last restart?

In the previous error log. The script reads log 0, the current one. Change the first argument of xp_readerrorlog to 1 for the log before it, and so on back through the retained logs.


Summary

Hundreds of login failures are rarely a mystery once they are grouped. One login from one address on a steady beat is a stale password. Many addresses trying sa and names that do not exist is a scan. Valid credentials failing on 4060 or 4064 is a missing database. Each needs a different fix, and resetting the password only fixes one of them.

Run this before you touch the login. Read client_host and reason first, check is_currently_locked because a wrong password against a locked login is still logged as Password did not match, and fix the source before you unlock anything.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *