DBA Scripts: Get Migration Login Audit and Post-Migration Validation

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

You are about to move logins to a new server, or you just did and something cannot log in. These 2 scripts cover both ends: Get-MigrationLoginAudit tells you what each login needs on the target, and Get-PostMigrationValidation gives you 13 counts and settings to run on both servers and diff, so you know whether the move landed.


When to Run These Scripts


The Scripts

Get-MigrationLoginAudit: What Each Login Needs

✓ Verified
  • Tested on: SQL Server 2025 (RTM CU8) 17.0.4075.5, Windows lab instance
  • Last verified: 2026-10-05 (both Example Output captures are real runs on the lab instance)
  • Permissions: VIEW ANY DEFINITION for both scripts, plus db_datareader on msdb for Get-PostMigrationValidation. Without VIEW ANY DEFINITION, neither script errors. They return fewer logins
  • Safety: read-only, impact low
/*
Script Name : Get-MigrationLoginAudit
Category    : migration
Purpose     : Audits all server-level principals that need to be migrated — SQL logins, Windows logins, and server roles — with migration risk and action per login type.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-migration-login-audit-and-post-migration-validation/)
Requires    : VIEW ANY DEFINITION (sysadmin and securityadmin have it implicitly)
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;

SELECT
    sp.name AS login_name,
    sp.type_desc AS login_type,
    sp.default_database_name,
    sp.is_disabled,
    sl.is_policy_checked,
    sl.is_expiration_checked,
    CASE
        WHEN sp.name = 'sa' THEN 'HIGH   - document or reset sa password; confirm enabled state is intentional'
        WHEN sp.type = 'S'
             AND sp.is_disabled = 0 THEN 'MEDIUM - active SQL login; script with SID preserved using Generate-LoginScript.ps1'
        WHEN sp.type = 'S'
             AND sp.is_disabled = 1 THEN 'LOW    - disabled SQL login; migrate or exclude intentionally'
        WHEN sp.type IN ('U', 'G') THEN 'LOW    - Windows auth; no password migration needed'
        WHEN sp.type = 'C' THEN 'HIGH   - certificate-backed login; script cert and login together'
        ELSE 'INFO'
    END AS migration_risk,
    CASE sp.type
        WHEN 'S' THEN 'Script with SID: Generate-LoginScript.ps1 - review output before running on target'
        WHEN 'U' THEN 'Windows user - verify AD account is accessible from target server domain/trust'
        WHEN 'G' THEN 'Windows group - verify group is accessible from target server domain/trust'
        WHEN 'C' THEN 'Certificate-backed - export certificate from master and recreate on target first'
        WHEN 'R' THEN 'Server role - verify role definition exists on target (custom roles only)'
        ELSE 'Review manually'
    END AS migration_action
FROM sys.server_principals sp
LEFT JOIN sys.sql_logins sl ON sl.principal_id = sp.principal_id
WHERE sp.type IN ('S', 'U', 'G', 'C', 'R')
  AND sp.name NOT LIKE '##%'
  AND sp.name NOT LIKE 'NT AUTHORITY\%'
  AND sp.name NOT LIKE 'NT SERVICE\%'
  AND sp.name NOT LIKE 'BUILTIN\%'
ORDER BY
    CASE sp.type WHEN 'S' THEN 1 WHEN 'U' THEN 2 WHEN 'G' THEN 3 ELSE 4 END,
    sp.is_disabled,
    sp.name;

migration_action is specific to each login type, so the output can go straight into your migration runbook.

Get-PostMigrationValidation: Counts to Diff Between Servers

/*
Script Name : Get-PostMigrationValidation
Category    : migration
Purpose     : Run on both SOURCE and TARGET and compare the CSV outputs to confirm the
              migration is complete and consistent. Surfaces database count mismatches,
              databases not ONLINE, orphaned users, and login count deltas.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-migration-login-audit-and-post-migration-validation/)
Requires    : VIEW ANY DEFINITION, VIEW ANY DATABASE, VIEW SERVER STATE, db_datareader on msdb
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;
SET QUOTED_IDENTIFIER ON;

/*
  DESIGN: Returns a single summary result set with one row per check category.
  Run this script on both servers. Export as CSV, then diff the two outputs.
  The 'value' column should match between source and target for each check_name.

  Checks performed:
    - user_database_count — total number of user databases
    - databases_not_online — any database not in ONLINE state
    - databases_missing_backup — databases with no recorded backup (should be 0 on target if just restored)
    - total_login_count — server principal count (SQL + Windows)
    - sql_login_count — SQL-authenticated logins
    - windows_login_count — Windows logins and groups
    - sysadmin_count — members of sysadmin role
    - linked_server_count — linked server definitions
    - agent_job_count — SQL Agent jobs
    - instance_version — SQL Server version string (major.minor.build)
    - instance_edition — SQL Server edition
    - max_server_memory_mb — sp_configure max server memory
    - maxdop — sp_configure max degree of parallelism
    - tempdb_data_file_count — TempDB data files (should match after setup)
*/

SELECT
    check_name,
    value,
    detail
FROM (

    -- Database count
    SELECT
        'user_database_count' AS check_name,
        CAST(COUNT(*) AS nvarchar(200)) AS value,
        'databases with database_id > 4' AS detail
    FROM sys.databases
    WHERE database_id > 4

    UNION ALL

    -- Databases not ONLINE
    SELECT
        'databases_not_online',
        CAST(COUNT(*) AS nvarchar(200)),
        ISNULL(
            STUFF((
                SELECT N', ' + name + N' (' + state_desc + N')'
                FROM sys.databases
                WHERE database_id > 4 AND state_desc <> N'ONLINE'
                FOR XML PATH(''), TYPE).value('.','nvarchar(max)')
            , 1, 2, N''),
            N'All ONLINE'
        )
    FROM sys.databases
    WHERE database_id > 4 AND state_desc <> N'ONLINE'

    UNION ALL

    -- Login count (all)
    SELECT
        'total_login_count',
        CAST(COUNT(*) AS nvarchar(200)),
        'SQL + Windows logins (excl. system accounts)'
    FROM sys.server_principals
    WHERE type IN ('S', 'U', 'G')
      AND name NOT LIKE N'##%##'
      AND name NOT IN (N'sa')
      AND name NOT LIKE N'NT SERVICE\%'
      AND name NOT LIKE N'NT AUTHORITY\%'
      AND name NOT LIKE N'BUILTIN\%'

    UNION ALL

    -- SQL login count
    SELECT
        'sql_login_count',
        CAST(COUNT(*) AS nvarchar(200)),
        'type = S (SQL authenticated)'
    FROM sys.server_principals
    WHERE type = 'S'
      AND name NOT LIKE N'##%##'
      AND name NOT IN (N'sa')

    UNION ALL

    -- Windows login count
    SELECT
        'windows_login_count',
        CAST(COUNT(*) AS nvarchar(200)),
        'type IN (U, G) - Windows users and groups'
    FROM sys.server_principals
    WHERE type IN ('U', 'G')
      AND name NOT LIKE N'NT SERVICE\%'
      AND name NOT LIKE N'NT AUTHORITY\%'
      AND name NOT LIKE N'BUILTIN\%'

    UNION ALL

    -- Sysadmin count
    SELECT
        'sysadmin_count',
        CAST(COUNT(*) AS nvarchar(200)),
        'members of sysadmin fixed server role'
    FROM sys.server_role_members srm
    INNER JOIN sys.server_principals r ON srm.role_principal_id = r.principal_id
    INNER JOIN sys.server_principals m ON srm.member_principal_id = m.principal_id
    WHERE r.name = N'sysadmin'
      AND m.name NOT LIKE N'##%##'
      AND m.name NOT IN (N'sa')

    UNION ALL

    -- Linked servers
    SELECT
        'linked_server_count',
        CAST(COUNT(*) AS nvarchar(200)),
        'sys.servers WHERE is_linked = 1'
    FROM sys.servers WHERE is_linked = 1

    UNION ALL

    -- SQL Agent jobs
    SELECT
        'agent_job_count',
        CAST(COUNT(*) AS nvarchar(200)),
        'msdb.dbo.sysjobs'
    FROM msdb.dbo.sysjobs

    UNION ALL

    -- Version
    SELECT
        'instance_version',
        CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(200)),
        CAST(SERVERPROPERTY('ProductLevel') AS nvarchar(200))

    UNION ALL

    -- Edition
    SELECT
        'instance_edition',
        CAST(SERVERPROPERTY('Edition') AS nvarchar(200)),
        CAST(SERVERPROPERTY('EngineEdition') AS nvarchar(200))

    UNION ALL

    -- Max server memory
    SELECT
        'max_server_memory_mb',
        CAST(CAST(value_in_use AS bigint) AS nvarchar(200)),
        'sp_configure ''max server memory (MB)'''
    FROM sys.configurations WHERE name = 'max server memory (MB)'

    UNION ALL

    -- MAXDOP
    SELECT
        'maxdop',
        CAST(value_in_use AS nvarchar(200)),
        'sp_configure ''max degree of parallelism'''
    FROM sys.configurations WHERE name = 'max degree of parallelism'

    UNION ALL

    -- TempDB data file count
    SELECT
        'tempdb_data_file_count',
        CAST(COUNT(*) AS nvarchar(200)),
        'sys.master_files WHERE database_id = 2 AND type = 0'
    FROM sys.master_files
    WHERE database_id = 2 AND type = 0

) chk
ORDER BY check_name;

Every row has the same 3 columns, check_name, value and detail, so the output diffs cleanly however many checks get added.


How To Run From The Repo

Clone DBA Tools, initialize and run either script:

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

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

# Audit every server-level principal and its specific migration action:
.\run.ps1 Get-MigrationLoginAudit

# Run on BOTH source and target, then diff the two CSVs:
.\run.ps1 Get-PostMigrationValidation

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

These scripts live in the repo at:


Example Output

Get-MigrationLoginAudit

One run on the lab instance returned 32 principals: 20 SQL logins, 1 Windows login, 1 certificate login and 10 server roles. These are 10 of them, with is_expiration_checked and migration_action left out for width.

Get-MigrationLoginAudit on the lab instanceexample output, not something to copy
login_name login_type default_database_name is_disabled is_policy_checked migration_risk --------------------- ------------------------ --------------------- ----------- ----------------- ----------------------------------------------------------------------------------- Lab1005_mig_admin SQL_LOGIN master 0 1 MEDIUM - active SQL login; script with SID preserved using Generate-LoginScript.ps1 Lab1005_mig_app SQL_LOGIN Lab1005_mig_db 0 1 MEDIUM - active SQL login; script with SID preserved using Generate-LoginScript.ps1 Lab1005_mig_legacy SQL_LOGIN master 0 0 MEDIUM - active SQL login; script with SID preserved using Generate-LoginScript.ps1 Lab1005_mig_nodb SQL_LOGIN Lab1005_mig_gone 0 1 MEDIUM - active SQL login; script with SID preserved using Generate-LoginScript.ps1 sa SQL_LOGIN master 0 1 HIGH - document or reset sa password; confirm enabled state is intentional Lab1005_mig_off SQL_LOGIN master 1 1 LOW - disabled SQL login; migrate or exclude intentionally HPAI01\Peter WINDOWS_LOGIN master 0 NULL LOW - Windows auth; no password migration needed Lab1005_mig_certlogin CERTIFICATE_MAPPED_LOGIN master 0 NULL HIGH - certificate-backed login; script cert and login together Lab1005_mig_role SERVER_ROLE NULL 0 NULL INFO sysadmin SERVER_ROLE NULL 0 NULL INFO

What the audit does not tell you:

  • Role membership. Every active SQL login reads MEDIUM, including Lab1005_mig_admin, which sits in dbcreator and a custom server role. Take roles from Permissions and Role Membership before you script.
  • A missing default database. Lab1005_mig_nodb has a default database that no longer exists, and nothing in the row says so. Signing in as it failed with “Cannot open user default database. Login failed.”
  • Asymmetric key logins. The lab had one and it is not in the output at all. The script reads types S, U, G, C and R, and that login is type K.

Get-PostMigrationValidation

On a real migration you run this on the source and the target and diff the two outputs. The lab run below is one instance, so it shows one side.

Get-PostMigrationValidation on the lab instanceexample output, not something to copy
check_name value detail ---------------------- ------------------------------------- --------------------------------------------------- agent_job_count 26 msdb.dbo.sysjobs databases_not_online 0 All ONLINE instance_edition Enterprise Developer Edition (64-bit) 3 instance_version 17.0.4075.5 RTM linked_server_count 1 sys.servers WHERE is_linked = 1 max_server_memory_mb 2048 sp_configure 'max server memory (MB)' maxdop 8 sp_configure 'max degree of parallelism' sql_login_count 19 type = S (SQL authenticated) sysadmin_count 10 members of sysadmin fixed server role tempdb_data_file_count 8 sys.master_files WHERE database_id = 2 AND type = 0 total_login_count 20 SQL + Windows logins (excl. system accounts) user_database_count 16 databases with database_id > 4 windows_login_count 1 type IN (U, G) - Windows users and groups

Diff the value column, not detail. instance_version’s detail reads RTM on a CU8 instance, because SERVERPROPERTY('ProductLevel') only reports RTM or a service pack.

The login counts match on count, not on identity. Dropping one login and creating another between two runs left total_login_count unchanged. To prove the same logins came across, with the same SIDs, run Login Migration Parity on both servers.


Understanding the Results

migration_risk
migration_action
The audit’s verdict per principal. HIGH: sa and certificate logins. MEDIUM: every active SQL login, whatever its roles. LOW: disabled SQL logins and Windows logins. INFO: server roles. sa is matched by name, so a renamed sa reads MEDIUM.Act when a row reads HIGH. For a certificate login, the certificate has to be backed up from master and created on the target before the login script will run. Server role rows include the 9 fixed roles, which already exist on the target. Only custom roles need scripting.
default_database_name
Shown, never checked. A login whose default database is missing on the server cannot sign in.Act when the name is not a database on the target. Restore the database first, or change the login’s default database with ALTER LOGIN ... WITH DEFAULT_DATABASE.
is_policy_checked
is_expiration_checked
Password policy and expiry for SQL logins. NULL for Windows logins, certificate logins and roles, which have no password.Act when a SQL login reads 0. Carry the setting to the target on purpose, not by accident, and decide whether a login with policy off should stay that way.
value
Get-PostMigrationValidation’s one comparable column. sysadmin_count includes disabled members and NT SERVICE accounts and leaves out sa.Act when any check_name differs between source and target. databases_not_online above 0 on the target stops the cutover. A matching login count still needs Login Migration Parity, and a different sysadmin_count needs Sysadmin Members.

Best Practices

  • Put sa and every disabled login in the migration runbook as their own line items. Both are easy to get subtly wrong on a target, and a disabled account migrated as enabled, or the reverse, is an avoidable mistake
  • Run both scripts on source and target under logins with the same permissions, or the counts will not compare
  • Add a check to Post-Migration Validation when a migration surprises you. Each check is one more UNION ALL row with its own check_name

Microsoft’s reference covers sys.server_principals, sp_configure and sys.databases in full.


Related Scripts

You may also find these scripts useful:


Frequently Asked Questions

Why does Migration Login Audit give a different action for each login type instead of one generic script?

Because the steps are different. A SQL login needs its SID preserved so its database permissions still map to it. A Windows login needs the target domain to resolve the account. A certificate login needs its certificate exported and recreated on the target first. One generic script is how real steps get missed.

Why doesn’t Post-Migration Validation do the diffing itself?

So it runs the same way on each server, with no link between them. Diffing the 2 outputs, by hand, in a spreadsheet or with a script, keeps the query simple and shows you exactly which check_name values don’t match.

Why is a login missing from the Migration Login Audit output?

2 reasons. The script reads principal types S, U, G, C and R, so an asymmetric key login (type K) never appears. And without VIEW ANY DEFINITION, sys.server_principals only shows your own login, the system logins and the fixed server roles. A lab login with no extra permission got 11 rows where sysadmin got 32, with no error.

Source and target show the same login count. Did every login come across?

Not necessarily. total_login_count is a count, so one missing login and one extra login cancel out: dropping a login and creating another between two runs left it unchanged. Login Migration Parity compares by name and SID.

Why does instance_version say RTM on a patched server?

The detail column comes from SERVERPROPERTY('ProductLevel'), which MS Docs lists as RTM, a service pack or a CTP. The cumulative update is in ProductUpdateLevel. Compare the build number in value: 17.0.4075.5 is CU8.

Does Post-Migration Validation need sysadmin?

No. VIEW ANY DEFINITION and db_datareader on msdb returned the same 13 rows as sysadmin. Without msdb access the whole query fails on msdb.dbo.sysjobs with Msg 229. Without VIEW ANY DEFINITION it runs and is wrong: the login count dropped to 1, sysadmin_count and tempdb_data_file_count to 0. Run it on both servers under logins with the same permissions, or the counts will not compare.


Summary

Different login types need different migration steps, and a migration is only done when the numbers match on both servers. Run Migration Login Audit before you script the logins, and Post-Migration Validation on both servers straight after cutover.

Comments

Leave a Reply

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