Comparing Logins Between Two Servers, Attribute by Attribute
After a migration the logins on the target usually look right. The names are all there, the
count matches, and the application connects. Then a week later somebody finds that a
decommissioned account is enabled again, or a report that runs on the first of the month fails
because a login’s default database was never created on the new server.
The reason those get through is that most migration checks count logins. A count is the one thing
that survives almost every mistake you can make: recreate every login by name with fresh SIDs and
no password policy, and the count still matches. This script compares the attributes instead. Run
it on the source, run it on the target, diff the two CSVs, and the differences are the migration
defects.
What a count check sees
Get-PostMigrationValidation and most runbooks
- 40 logins on the source, 40 on the target. PASS
- Nothing about which SID each one carries
- Nothing about whether a disabled account arrived enabled
- Nothing about a default database that does not exist here
- Passes a migration where every single login is wrong
What a parity check sees
One fingerprint row per login, diffed
sid_hexdiffers, so every database user mapped to it is orphanedis_disabledflipped from True to Falsedefault_db_statereads MISSINGpassword_hash_iddiffers, so somebody re-typed it- A row on the source with no matching row on the target
Why Login Parity Matters
- A login recreated by name gets a brand new SID, so every database user mapped to the old one
is orphaned. This is the single most common migration defect and it presents as a permissions
problem, not as a migration problem CREATE LOGINalways produces an enabled login. There is no syntax for creating one
disabled, so a deliberately disabled account arrives on the target live, with its original
password still valid- A default database that does not exist on the target stops the login connecting at all, and
the error you get back reads like an authentication failure - Password policy silently relaxing is a security finding, not a footnote, and nothing warns you
- This is a comparison, not a fixer. It changes nothing. It gives you two CSVs and a diff
When to Run This Script
- Immediately after any migration, restore-to-new-server, or DR build, before you hand it over
- Before a cutover, on the source, so you have a fingerprint to compare against afterwards
- When an application authenticates fine on the old server and fails on the new one
- Periodically against a DR replica that is supposed to mirror production’s logins
- After anyone has run a login-transfer script, including this repo’s own generator
The Script
Read-only, no cursors, no temp tables, and no connection between the two servers. That last part is
deliberate: run it twice and diff, and it works across an air gap with no linked server to set up.
/*
Script Name : Get-LoginMigrationParity
Category : migration
Purpose : Compare logins between two servers to verify a migration. Returns one comparable
fingerprint row per login (SID, disabled state, deny connect, default database
and language, password policy, password hash, server roles). Run on SOURCE and
TARGET, export both as CSV, then diff. Catches what a login migration silently
drops and a login count check cannot see.
Author : Peter Whyte (https://sqldba.blog/dba-scripts-get-login-migration-parity/)
Requires : VIEW ANY DEFINITION (VIEW SERVER STATE for full detail); CONTROL SERVER to
compare password hashes, which are otherwise reported as 'no-permission'
Notes : Get-PostMigrationValidation.sql compares COUNTS. A count matches while every
login on the target is enabled, mapped to the wrong default database and
carrying a fresh SID, so a count check passes a migration that is wrong.
This script compares the attributes themselves.
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;
/*
DESIGN: deliberately one flat, ordered, deterministic row per login so a plain text diff
of two CSVs is the whole comparison. No cursors, no temp tables, no server-to-server
connection: run it twice and diff, which works across an air gap and needs no linked server.
Columns chosen because each one is something a real migration loses quietly:
sid_hex - a login recreated BY NAME gets a fresh SID, orphaning every database
user mapped to it. The single most common migration defect.
is_disabled - CREATE LOGIN always produces an ENABLED login. A disabled account
comes back live on the target with its original password valid.
connect_denied - an explicit DENY CONNECT SQL is a separate object from is_disabled
and is not carried by CREATE LOGIN either.
default_database - if it does not exist on the target the login cannot connect at all.
default_language - changes date parsing and error message language for that session.
check_policy /
check_expiration - password policy silently relaxing on the target is a security finding.
password_hash_id - short fingerprint of the stored hash, so you can compare without
putting a full hash in a CSV that gets emailed around. Read it
carefully: the hash is SALTED, so the same password typed twice
produces two different hashes. A matching fingerprint therefore
proves the login was scripted WITH PASSWORD = <hash> HASHED and the
hash carried across. A differing one means somebody re-typed the
password, even if they typed the same password.
server_roles - fixed and user-defined server role membership, comma separated.
HOW TO USE
1. Run on SOURCE, save CSV.
2. Run on TARGET, save CSV.
3. Diff. Every row should match except password_hash_id for Windows logins (always 'n/a').
4. Rows present on source and missing on target are logins that did not migrate.
This script never writes. Nothing here changes a login.
*/
SELECT
p.name AS login_name,
p.type_desc AS login_type,
-- A login recreated by name gets a new SID. This column is the one that matters most.
CONVERT(nvarchar(200), p.sid, 1) AS sid_hex,
p.is_disabled,
-- DENY CONNECT SQL is a permission, not a flag on the principal, so it is easy to miss.
CASE WHEN EXISTS (
SELECT 1
FROM sys.server_permissions sp
WHERE sp.grantee_principal_id = p.principal_id
AND sp.permission_name = N'CONNECT SQL'
AND sp.state = 'D') -- D = DENY
THEN 1 ELSE 0 END AS connect_denied,
ISNULL(p.default_database_name, N'(none)') AS default_database,
-- Does that default database actually exist here? Blank on the target means the login
-- is created but cannot connect, which reads as an authentication problem.
CASE WHEN p.default_database_name IS NULL THEN 'n/a'
WHEN DB_ID(p.default_database_name) IS NULL THEN 'MISSING'
ELSE 'present' END AS default_db_state,
ISNULL(p.default_language_name, N'(none)') AS default_language,
CASE WHEN p.type = 'S' THEN CAST(sl.is_policy_checked AS nvarchar(5)) ELSE 'n/a' END AS check_policy,
CASE WHEN p.type = 'S' THEN CAST(sl.is_expiration_checked AS nvarchar(5)) ELSE 'n/a' END AS check_expiration,
-- Fingerprint, not the hash: enough to prove the password came across, safe to share.
CASE WHEN p.type <> 'S' THEN 'n/a'
WHEN sl.password_hash IS NULL THEN 'no-permission'
ELSE RIGHT(CONVERT(nvarchar(300), sl.password_hash, 1), 12)
END AS password_hash_id,
STUFF((
SELECT N', ' + r.name
FROM sys.server_role_members srm
INNER JOIN sys.server_principals r ON srm.role_principal_id = r.principal_id
WHERE srm.member_principal_id = p.principal_id
ORDER BY r.name
FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, N'') AS server_roles,
CONVERT(varchar(19), p.create_date, 120) AS create_date,
CONVERT(varchar(19), p.modify_date, 120) AS modify_date
FROM sys.server_principals AS p
LEFT JOIN sys.sql_logins AS sl ON sl.principal_id = p.principal_id
-- 'S' = SQL_LOGIN, 'U' = WINDOWS_LOGIN, 'G' = WINDOWS_GROUP.
-- There is no type 'W'; using one silently returns no Windows logins at all.
WHERE p.type IN ('S', 'U', 'G')
AND p.name NOT LIKE N'##%##' -- certificate-backed internal principals
AND p.name NOT LIKE N'NT SERVICE\%' -- belong to the target's own installation
AND p.name NOT LIKE N'NT AUTHORITY\%'
AND p.name NOT LIKE N'BUILTIN\%'
ORDER BY p.name;
How To Run From The Repo
# Clone dba-tools repo:
git clone https://github.com/peterwhyte-lgtm/dba-tools
# Initialize environment:
cd dba-tools
.\Initialize-Environment.ps1
# Local instance, results in the terminal:
.\run.ps1 Get-LoginMigrationParity
# The real workflow: CSV from each server, then diff
.\powershell\wrappers\migration\Get-LoginMigrationParity.ps1 -ServerInstance SOURCE -OutputFormat Csv -OutputPath .\output-files\migration\source-logins.csv
.\powershell\wrappers\migration\Get-LoginMigrationParity.ps1 -ServerInstance TARGET -OutputFormat Csv -OutputPath .\output-files\migration\target-logins.csv
Then diff them with whatever you already use:
Compare-Object (Import-Csv .\output-files\migration\source-logins.csv) `
(Import-Csv .\output-files\migration\target-logins.csv) `
-Property login_name, sid_hex, is_disabled, default_database, default_language, password_hash_id
This script lives in the repo at:
sql/migration/Get-LoginMigrationParity.sqlpowershell/wrappers/migration/Get-LoginMigrationParity.ps1
Example Output
One row per login, ordered by name, so a plain text diff is the whole comparison. Login names and
the domain below are illustrative; the measured source-versus-target comparison underneath them is
real output from a SQL Server 2025 instance.
login_name login_type is_disabled default_db_state default_language check_policy password_hash_id server_roles
AppSupport_User SQL_LOGIN False present us_english 0 BDB2A33B9EF5
CONTOSO\dbateam WINDOWS_GROUP False present us_english n/a n/a sysadmin
CONTOSO\svc_etl WINDOWS_LOGIN False present us_english n/a n/a
DBA_Junior SQL_LOGIN False present us_english 0 87307ADA3B82 sysadmin
OldContractor SQL_LOGIN True present us_english 0 E2C4C68BDF00 sysadmin
ReportingUser SQL_LOGIN False MISSING us_english 1 78CAAEE3A84A
sa SQL_LOGIN False present us_english 1 1C0A58EEB04F sysadmin
The case this was written for, measured
A throwaway login was set up the way a real one often is: disabled, in securityadmin, on the
British language, with password policy on. It was then migrated the naive way, recreated by name
with the same password typed in again. Here is what each check saw:
sid_hex is_disabled language policy password_hash_id roles
SOURCE 0x4E3C11409C4560468284478D71FFDA9F True British True B3856401B2FD 1
TARGET 0x948A5E6C96CF4F4B9671C50720AEB08D False us_english True 2DF417F85CD9 1
count check source 18 -> target 18 PASSES, migration looks complete
The count check passes. Four attributes are wrong: a new SID that orphans every database user
mapped to it, a security account quietly re-enabled, a language change that alters date parsing,
and a password hash that proves the password was re-typed rather than carried across.
Understanding the Results
| Column | What a difference means |
|---|---|
sid_hex |
The most important row in the file. A different SID means the login was recreated by name, and every database user that was mapped to it is now orphaned. Fix by scripting the login with its original SID, or by re-mapping with ALTER USER ... WITH LOGIN. |
is_disabled |
True on the source and False on the target means a disabled account is live again, with its original password. Treat this as a security finding, not a tidy-up. |
connect_denied |
An explicit DENY CONNECT SQL. It is a permission, not a flag on the principal, so login-transfer scripts almost never carry it. A login can be enabled and still correctly refused. |
default_db_state |
MISSING means the login’s default database does not exist here. The login is created but cannot connect, and the error looks like an authentication problem. |
default_language |
Changes date parsing and the language of error messages for that session. A silent switch to us_english can change how a string date is interpreted. |
check_policy |
Password policy and expiry enforcement. Relaxing on the target is a real weakening, and it never announces itself. |
password_hash_id |
A fingerprint of the stored hash. The hash is salted, so the same password typed twice produces two different hashes. A match therefore proves the login was scripted with WITH PASSWORD = <hash> HASHED. A difference means somebody re-typed it. |
server_roles |
Fixed and user-defined server roles. Check for both directions: a role that did not come across, and a role somebody added on the target that was never on the source. |
A row present on the source and absent from the target is the simplest finding of all: that login
did not migrate.
Best Practices
- Take the source fingerprint before the cutover, not after. Once the old server is
decommissioned you have nothing left to compare against - Keep both CSVs with the change record. They are small, they are plain text, and they are the
evidence that the migration was complete - Diff on
login_nameandsid_hexfirst. Everything else is a detail next to a SID mismatch - Expect
password_hash_idto readn/afor Windows logins. There is no stored hash, and that
is correct rather than a gap - Run it as a sysadmin, or accept a gap. Without
CONTROL SERVERthe hash column reads
no-permission, and the script says so rather than quietly reporting a blank - Do not stop at logins. Parity at the server level says nothing about database users. Pair it
with Fix Orphaned Users
Related Scripts
You may also find these scripts useful:
- SQL Server Migration Script Generators (hub)
- Generate Login Script, the generator that produces the DDL this script then verifies
- Migration Login Audit and Post-Migration Validation, the count-level companion to this one
- Fix Orphaned Users, for when the SID comparison finds a mismatch
- Generate User Mapping Script
- Get Migration Risk Assessment
- Get Sysadmin Members
- DBA Scripts: The Complete Guide, the map across every script on this site
Frequently Asked Questions
Why not just compare login counts?
Because a count survives nearly every mistake you can make. Recreate all forty logins by name, with fresh SIDs, no password policy and every disabled account enabled, and the count is still forty. Counting tells you nothing arrived missing. It cannot tell you anything arrived wrong.
Do I need a linked server between the two instances?
No, and that is deliberate. The script runs standalone on one instance and writes a CSV. You compare the files, not the servers. That works across an air gap, across a firewall, and after the source has been taken off the network, as long as you captured its CSV first.
The password_hash_id differs but I know the password is the same. Is that a bug?
No, and it is the most useful column to understand properly. SQL Server salts the password hash, so creating two logins with an identical password produces two completely different hashes. A matching fingerprint proves the login was scripted with WITH PASSWORD = <hash> HASHED and the stored hash was carried across. A differing one tells you somebody re-typed the password by hand, which is worth knowing even when they typed the right one.
Why are NT SERVICE and BUILTIN logins excluded?
They belong to the target instance’s own installation rather than to your migration. Every SQL Server creates its own service accounts and built-in groups, so including them would guarantee a difference on every single run and train you to ignore the output.
What does connect_denied show that is_disabled does not?
They are two different mechanisms that look similar from the outside. is_disabled is a flag on the principal. DENY CONNECT SQL is a permission stored separately, and a login-transfer script that carries the principal will not carry the permission. A login can be enabled on the target and still correctly refused on the source, which is a difference worth seeing.
Can I run this against a server I only have read access to?
Mostly. VIEW ANY DEFINITION gets you every column except the password fingerprint, which needs CONTROL SERVER. Without it the column reads no-permission rather than blank, so you can tell the difference between "no hash" and "not allowed to look".
Summary
Counting logins proves nothing arrived missing. It cannot prove anything arrived correct. This
script produces one deterministic row per login covering the attributes a migration actually drops:
the SID, the disabled state, an explicit deny, the default database and language, the password
policy, a safe fingerprint of the stored hash, and server role membership. Run it on both servers,
diff the CSVs, and read sid_hex first.
Leave a Reply