DBA Scripts: Get Login Security Audit

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

What’s Actually Happening at the Login Level, Not Just Who’s Allowed

Permissions and Role Membership covers who can do what. These three scripts cover something different: what’s actually happening at the login layer right now. Get-FailedLoginSummary surfaces brute-force patterns and locked accounts from the error log. Get-LoginLastActivity shows current session status and connection details for every login. Get-WeakLoginSettings flags SQL logins with password policy off, expiration off, or sa enabled, the configuration gaps that make a login easier to compromise in the first place.


Why Login Security Matters

  • A pattern of failed logins from one client host is a real, actionable brute-force signal, not just noise in the error log
  • SQL Server does not track “last login time” natively without a configured Server Audit, so knowing what’s actually available (current sessions, not history) prevents a false assumption during an investigation
  • Password policy and expiration checks being off is a silent, common gap, SQL logins created without CHECK_POLICY inherit none of Windows’ password complexity or lockout protection
  • sa enabled is one of the highest-value single findings in any security review, it’s the most-targeted account name in the world for exactly this reason

When to Run These Scripts

  • Get-FailedLoginSummary — when investigating suspicious activity, or as a routine health check (it’s part of the standard health-check suite)
  • Get-LoginLastActivity — when reviewing which logins are actually in active use before disabling or removing anything
  • Get-WeakLoginSettings — during any security review or when inheriting a server, it’s also part of the standard health-check suite
  • Together, as a login-level security pass alongside Permissions and Role Membership

The Scripts

1. Get-FailedLoginSummary — Brute-Force Patterns and Lockout State

✓ Verified
  • Tested on: SQL Server 2025 (RTM CU5), Windows lab instance
  • Last verified: 2026-08-07 (all 3 scripts on this page run, saved outputs from real runs)
  • Permissions: VIEW ANY DATABASE, sysadmin to see LOGINPROPERTY details · VIEW SERVER STATE, VIEW ANY DEFINITION · VIEW SERVER STATE, sysadmin (for LOGINPROPERTY on other logins), EXECUTE on xp_readerrorlog
  • Safety: read-only, impact low
Any thresholds in this script are operational heuristics; claim types are labelled where they appear in the text.
/*
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
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;

-- INSERT...EXEC cannot be used inside a CTE so materialise to a temp table first
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
        SUBSTRING(
            log_text,
            CHARINDEX('''', log_text) + 1,
            CHARINDEX('''', log_text, CHARINDEX('''', log_text) + 1) - CHARINDEX('''', log_text) - 1
        ) AS login_name,
        -- Client IP/host is in the trailing [CLIENT: ...] tag
        CASE
            WHEN 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,
        -- 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 '%password did not match%' 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,
        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
)
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 4818 THEN 'Password does not meet complexity requirements'
        ELSE 'Error ' + CAST(agg.error_code AS VARCHAR(10))
    END AS error_description,
    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.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;

2. Get-LoginLastActivity — Current Session Status and Connection Details

/*
Script Name : Get-LoginLastActivity
Category    : monitoring
Purpose     : All SQL and Windows logins with current session status, connection details, and disabled/locked state.
              Note: SQL Server does not record "last login time" natively without a SQL Server Audit configured.
              This script shows what is available: current active sessions and login metadata.
              For historical last-login tracking, enable a Server Audit with LOGIN action group.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-login-security-audit/)
Requires    : VIEW SERVER STATE, VIEW ANY DEFINITION
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;

SELECT
    sp.name AS login_name,
    sp.type_desc AS login_type,
    sp.is_disabled,
    sp.create_date,
    sp.modify_date,
    /* Current session info — NULL when not connected right now */
    COUNT(s.session_id) AS active_sessions,
    MIN(s.login_time) AS earliest_current_session,
    MAX(s.login_time) AS latest_current_session,
    /* Connection detail (most recent active session) */
    MAX(c.client_net_address) AS last_client_ip,
    MAX(s.host_name) AS last_host_name,
    MAX(s.program_name) AS last_program_name,
    MAX(c.auth_scheme) AS auth_scheme,
    /* Permission summary */
    IS_SRVROLEMEMBER('sysadmin', sp.name) AS is_sysadmin,
    IS_SRVROLEMEMBER('securityadmin', sp.name) AS is_securityadmin
FROM sys.server_principals sp
LEFT JOIN sys.dm_exec_sessions s ON s.login_name = sp.name AND s.is_user_process = 1
LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
WHERE sp.type IN ('S', 'U', 'G') /* SQL login, Windows user, Windows group */
  AND sp.name NOT LIKE '##%' /* exclude internal system logins */
GROUP BY sp.name, sp.type_desc, sp.is_disabled, sp.create_date, sp.modify_date
ORDER BY active_sessions DESC, sp.is_disabled, sp.name;

3. Get-WeakLoginSettings — Password Policy, Expiration, and SA Status

/*
Script Name : Get-WeakLoginSettings
Category    : security-and-permissions
Purpose     : Identify SQL logins with weak security settings: policy off, expiration off, or sa enabled.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-login-security-audit/)
Requires    : VIEW ANY DATABASE, sysadmin to see LOGINPROPERTY details
HealthCheck : Yes
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;

SELECT
    sl.name AS login_name,
    sl.is_disabled,
    sl.is_policy_checked,
    sl.is_expiration_checked,
    CAST(LOGINPROPERTY(sl.name, 'PasswordLastSetTime') AS DATETIME) AS password_last_set,
    CAST(LOGINPROPERTY(sl.name, 'IsLocked') AS BIT) AS is_locked,
    CAST(LOGINPROPERTY(sl.name, 'IsMustChange') AS BIT) AS must_change_password,
    sl.default_database_name,
    sl.create_date,
    sl.modify_date,
    CASE
        WHEN sl.name = 'sa' AND sl.is_disabled = 0 THEN 'SA_ENABLED'
        WHEN sl.is_policy_checked = 0 THEN 'PASSWORD_POLICY_OFF'
        WHEN sl.is_expiration_checked = 0 THEN 'EXPIRATION_OFF'
        ELSE 'OK'
    END AS risk_flag
FROM sys.sql_logins AS sl
WHERE sl.name NOT LIKE '##%'
ORDER BY
    CASE
        WHEN sl.name = 'sa' AND sl.is_disabled = 0 THEN 0
        WHEN sl.is_policy_checked = 0 THEN 1
        WHEN sl.is_expiration_checked = 0 THEN 2
        ELSE 3
    END,
    sl.name;

How To Run From The Repo

Clone DBA Tools, initialize and run whichever script answers your question:

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

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

# Failed login patterns and lockout state:
.\run.ps1 Get-FailedLoginSummary

# Current session status and connection details per login:
.\run.ps1 Get-LoginLastActivity

# Weak password policy, expiration, and sa status:
.\run.ps1 Get-WeakLoginSettings

# Any of these against a remote sql server:
.\run.ps1 Get-WeakLoginSettings -ServerInstance SQLSERVER01

These scripts live in the repo at:


Example Output

Get-FailedLoginSummary (1 row, a genuine real failed login attempt captured):

login_name client_host error_code error_description failure_count
demo_bad_login 192.168.1.109 18456 Login failed (bad password or login does not exist) 1

Get-LoginLastActivity (17 rows, condensed here):

login_name login_type is_disabled active_sessions
pete SQL_LOGIN False 0
sa SQL_LOGIN False 0
DemoDisabledAdmin SQL_LOGIN True 0

Get-WeakLoginSettings (10 rows, condensed here, a genuinely useful real finding: most logins on this box have password policy off):

login_name is_policy_checked is_expiration_checked risk_flag
sa True False OK
pete True False OK
AppSupport_User False False PASSWORD_POLICY_OFF
DBA_Junior False False PASSWORD_POLICY_OFF
DemoAdminUser False False PASSWORD_POLICY_OFF

Understanding the Results

  • failure_count >= 50 — flagged CRITICAL, likely brute-force or a misconfigured application retrying with the wrong credentials, not casual mistyping
  • is_currently_locked = 1 — the account is locked out right now from repeated bad password attempts; investigate before unlocking, don’t assume it’s benign
  • active_sessions = 0 for a login you expected to be in use — worth checking, either the login isn’t actually needed anymore or something changed in how it connects
  • risk_flag = PASSWORD_POLICY_OFF — the SQL login was created without CHECK_POLICY = ON, meaning it inherits none of Windows’ complexity, history, or lockout protections; this is a genuinely common gap, several logins on this lab box show it
  • risk_flag = SA_ENABLED — the single highest-priority finding this script can return; sa is the most-targeted login name in the world, disable it if an equivalent named sysadmin account exists

Best Practices

  • Treat any CRITICAL result from Get-FailedLoginSummary as worth same-day investigation, especially a pattern from one specific client host
  • Fix PASSWORD_POLICY_OFF and EXPIRATION_OFF findings directly with ALTER LOGIN [name] WITH CHECK_POLICY = ON, CHECK_EXPIRATION = ON, rather than just noting them
  • Disable sa wherever a named, audited sysadmin account already exists to do the same job
  • Use Get-LoginLastActivity before disabling or dropping any login, current session absence doesn’t confirm the login is unused, only that nothing’s connected right now

Microsoft Learn: sys.sql_logins · sys.dm_exec_sessions · sys.dm_exec_connections


Related Scripts

You may also find these scripts useful:


Frequently Asked Questions

Does this tell me exactly when a login last connected, historically?

Not by default. SQL Server doesn’t track historical last-login time without a configured Server Audit with the LOGIN action group enabled. Get-LoginLastActivity shows what’s available without one: current active sessions and login metadata, genuinely useful, but not a substitute for audit-based history if you need it.

Why does SQL Server 2025 need xp_readerrorlog instead of the ring buffer?

RING_BUFFER_SECURITY_ERROR no longer captures 18456 failed-login events on SQL Server 2025 in this repo’s testing. xp_readerrorlog reading the actual error log text is the reliable, cross-version source this script relies on instead.


Summary

Login-level security is a different question from permissions: it’s about what’s actually happening right now (failed attempts, active sessions) and what’s configured to make compromise harder or easier (password policy, sa status). These three scripts turn that into a direct, actionable checklist rather than something inferred indirectly from other checks.

Run all three as part of any security review, and treat SA_ENABLED and a CRITICAL failed-login pattern as the two findings that deserve immediate attention over everything else here.

Comments

Leave a Reply

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