DBA Scripts: Generate Linked Server Script

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

Recreating Linked Servers and Login Mappings for a Migration

If linked server queries fail on the new server after a migration, with Msg 7416 Access to the remote server is denied because no login-mapping exists or Msg 18456 for the remote login, the linked servers came across without their login mappings or passwords. This script writes the sp_addlinkedserver and sp_addlinkedsrvlogin calls for every linked server on the source, for you to review and run on the target. Stored passwords can’t be read back out, so those mappings come with an ENTER_PASSWORD_HERE placeholder.


Why a Generated Linked Server Script Matters

  • Login mappings are easy to get wrong by hand, especially telling an impersonation mapping from an explicit remote login.
  • You review the exact sp_addlinkedserver and sp_addlinkedsrvlogin calls before anything runs.
  • It’s a generator, not a migration tool. It doesn’t move data or run the cutover.

When to Run This Script

  • Migrating databases to new hardware or a new SQL Server version, on a source instance that has linked servers configured
  • Consolidating several instances onto one server
  • Standing up a DR or failover target that needs the same linked server topology as production

The Script

Run it as sysadmin, or with both ALTER ANY LINKED SERVER and VIEW ANY DEFINITION; with less it stops with an error rather than writing a wrong script.

PermissionsWith only one of the two, the script stops with Msg 50000 and says what it needs. That is deliberate: without ALTER ANY LINKED SERVER, provider_string reads NULL (MS Docs), and without VIEW ANY DEFINITION a mapping for one login cannot be named, so either way the output would be wrong.
/*
Script Name : Generate-LinkedServerScript
Category    : migration
Purpose     : Generate sp_addlinkedserver + sp_serveroption + sp_addlinkedsrvlogin DDL for all linked servers.
              Run on SOURCE server. Execute the output on TARGET after migration.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-generate-linked-server-script/)
Requires    : CONTROL SERVER (sysadmin has it), or ALTER ANY LINKED SERVER plus VIEW ANY DEFINITION.
              Without ALTER ANY LINKED SERVER provider_string reads NULL; without principal visibility
              a per-login mapping cannot be named. Either would emit wrong DDL, so the script stops.
Notes       : Every sp_serveroption value is scripted explicitly, because sp_addlinkedserver
              defaults differ by product (the 'SQL Server' form starts with rpc and rpc out on).
              A source with no default mapping ("Not be made") gets sp_droplinkedsrvlogin
              @locallogin = NULL, otherwise the target would let every login through.
              Stored remote passwords cannot be scripted: those lines carry ENTER_PASSWORD_HERE.
              Run unedited, the mapping is created with that literal password and the first
              query through the linked server fails with Msg 18456.
              A linked server that already exists on the target is skipped, not altered.
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;
SET QUOTED_IDENTIFIER ON;

/*
  DESIGN: Linked server login mappings with stored remote credentials cannot have passwords
  scripted, SQL Server does not expose them. Mappings using stored credentials are scripted
  with an ENTER_PASSWORD_HERE placeholder and a clear comment. Re-enter the remote password
  manually on the target for each HIGH-risk mapping flagged by Get-LinkedServerSecurity.sql.

  Impersonation mappings (useself = 1) and no-credential mappings are scripted exactly.
*/

-- With less than this the output is wrong with no error: provider strings drop out, and a
-- per-login mapping whose login cannot be seen is written as a mapping for EVERY login.
IF HAS_PERMS_BY_NAME(NULL, NULL, N'ALTER ANY LINKED SERVER') <> 1
   OR EXISTS (SELECT 1 FROM sys.linked_logins ll
              WHERE ll.local_principal_id <> 0
                AND NOT EXISTS (SELECT 1 FROM sys.server_principals sp WHERE sp.principal_id = ll.local_principal_id))
BEGIN
    RAISERROR(N'Generate-LinkedServerScript needs CONTROL SERVER (or ALTER ANY LINKED SERVER plus VIEW ANY DEFINITION). Without it provider strings read as NULL and per-login mappings cannot be named, so the generated script would be wrong.', 16, 1);
    RETURN;
END;

DECLARE @ddl  NVARCHAR(MAX) = N'';
DECLARE @crlf NCHAR(2)      = CHAR(13) + CHAR(10);

SET @ddl = @ddl
    + N'-- ================================================================' + @crlf
    + N'-- Linked Server Migration Script' + @crlf
    + N'-- Source  : ' + @@SERVERNAME + @crlf
    + N'-- Generated: ' + CONVERT(NVARCHAR(30), GETDATE(), 120) + @crlf
    + N'-- IMPORTANT: Stored credentials (remote_login / rmtpassword) cannot' + @crlf
    + N'-- be scripted. Lines marked ENTER_PASSWORD_HERE require manual entry.' + @crlf
    + N'-- Run Get-LinkedServerSecurity.sql to identify HIGH-risk mappings.' + @crlf
    + N'-- ================================================================' + @crlf + @crlf;

-- ── One block per linked server ───────────────────────────────────────────────

DECLARE @ls_name      NVARCHAR(128);
DECLARE @ls_q         NVARCHAR(300);
DECLARE @ls_product   NVARCHAR(128);
DECLARE @ls_provider  NVARCHAR(128);
DECLARE @ls_datasrc   NVARCHAR(4000);
DECLARE @ls_location  NVARCHAR(4000);
DECLARE @ls_provstr   NVARCHAR(4000);
DECLARE @ls_catalog   NVARCHAR(128);
DECLARE @ls_rpc       bit;
DECLARE @ls_rpc_out   bit;
DECLARE @ls_data      bit;
DECLARE @ls_collation bit;
DECLARE @ls_remcoll   bit;
DECLARE @ls_lazy      bit;
DECLARE @ls_promote   bit;
DECLARE @ls_collname  NVARCHAR(128);
DECLARE @ls_conn_to   INT;
DECLARE @ls_query_to  INT;
DECLARE @ls_default   bit;

DECLARE ls_cur CURSOR LOCAL FAST_FORWARD FOR
    SELECT
        s.name,
        ISNULL(s.product,   N''),
        ISNULL(s.provider,  N'SQLNCLI'),
        ISNULL(s.data_source, N''),
        ISNULL(CAST(s.location AS NVARCHAR(4000)), N''),
        ISNULL(CAST(s.provider_string AS NVARCHAR(4000)), N''),
        ISNULL(s.catalog,   N''),
        s.is_remote_login_enabled,
        s.is_rpc_out_enabled,
        s.is_data_access_enabled,
        s.is_collation_compatible,
        s.uses_remote_collation,
        s.lazy_schema_validation,
        s.is_remote_proc_transaction_promotion_enabled,
        s.collation_name,
        s.connect_timeout,
        s.query_timeout,
        CASE WHEN EXISTS (SELECT 1 FROM sys.linked_logins d
                          WHERE d.server_id = s.server_id AND d.local_principal_id = 0)
             THEN 1 ELSE 0 END
    FROM sys.servers s
    WHERE s.is_linked = 1
    ORDER BY s.name;

OPEN ls_cur;
FETCH NEXT FROM ls_cur INTO
    @ls_name, @ls_product, @ls_provider, @ls_datasrc, @ls_location, @ls_provstr, @ls_catalog,
    @ls_rpc, @ls_rpc_out, @ls_data, @ls_collation, @ls_remcoll, @ls_lazy, @ls_promote,
    @ls_collname, @ls_conn_to, @ls_query_to, @ls_default;

WHILE @@FETCH_STATUS = 0
BEGIN
    SET @ls_q = REPLACE(@ls_name, N'''', N'''''');

    SET @ddl = @ddl
        + N'-- Linked Server: ' + @ls_name + @crlf
        + N'IF NOT EXISTS (SELECT 1 FROM sys.servers WHERE name = N''' + @ls_q + N''' AND is_linked = 1)' + @crlf
        + N'BEGIN' + @crlf
        + N'    EXEC sp_addlinkedserver' + @crlf
        + N'        @server     = N''' + @ls_q + N''',' + @crlf
        -- The 'SQL Server' form takes no provider or properties (Msg 15428 if it gets any)
        + CASE WHEN @ls_product = N'SQL Server'
               THEN N'        @srvproduct = N''SQL Server''' + @crlf
               ELSE N'        @srvproduct = N''' + REPLACE(@ls_product,  N'''', N'''''') + N''',' + @crlf
                  + N'        @provider   = N''' + REPLACE(@ls_provider, N'''', N'''''') + N''',' + @crlf
                  + N'        @datasrc    = N''' + REPLACE(@ls_datasrc,  N'''', N'''''') + N'''' + @crlf
                  + CASE WHEN @ls_location <> N''
                         THEN N'       ,@location  = N''' + REPLACE(@ls_location, N'''', N'''''') + N'''' + @crlf
                         ELSE N'' END
                  + CASE WHEN @ls_provstr <> N''
                         THEN N'       ,@provstr   = N''' + REPLACE(@ls_provstr,  N'''', N'''''') + N'''' + @crlf
                         ELSE N'' END
                  + CASE WHEN @ls_catalog <> N''
                         THEN N'       ,@catalog   = N''' + REPLACE(@ls_catalog,  N'''', N'''''') + N'''' + @crlf
                         ELSE N'' END
          END
        + N'    ;' + @crlf + @crlf;

    -- Options: every one scripted, because the defaults differ by product
    SET @ddl = @ddl
        + N'    EXEC sp_serveroption @server = N''' + @ls_q + N''', @optname = N''rpc'', @optvalue = N''' + CASE @ls_rpc WHEN 1 THEN N'true' ELSE N'false' END + N''';' + @crlf
        + N'    EXEC sp_serveroption @server = N''' + @ls_q + N''', @optname = N''rpc out'', @optvalue = N''' + CASE @ls_rpc_out WHEN 1 THEN N'true' ELSE N'false' END + N''';' + @crlf
        + N'    EXEC sp_serveroption @server = N''' + @ls_q + N''', @optname = N''data access'', @optvalue = N''' + CASE @ls_data WHEN 1 THEN N'true' ELSE N'false' END + N''';' + @crlf
        + N'    EXEC sp_serveroption @server = N''' + @ls_q + N''', @optname = N''collation compatible'', @optvalue = N''' + CASE @ls_collation WHEN 1 THEN N'true' ELSE N'false' END + N''';' + @crlf
        + N'    EXEC sp_serveroption @server = N''' + @ls_q + N''', @optname = N''use remote collation'', @optvalue = N''' + CASE @ls_remcoll WHEN 1 THEN N'true' ELSE N'false' END + N''';' + @crlf
        + CASE WHEN @ls_collname IS NOT NULL
               THEN N'    EXEC sp_serveroption @server = N''' + @ls_q + N''', @optname = N''collation name'', @optvalue = N''' + @ls_collname + N''';' + @crlf
               ELSE N'' END
        + N'    EXEC sp_serveroption @server = N''' + @ls_q + N''', @optname = N''lazy schema validation'', @optvalue = N''' + CASE @ls_lazy WHEN 1 THEN N'true' ELSE N'false' END + N''';' + @crlf
        + N'    EXEC sp_serveroption @server = N''' + @ls_q + N''', @optname = N''remote proc transaction promotion'', @optvalue = N''' + CASE @ls_promote WHEN 1 THEN N'true' ELSE N'false' END + N''';' + @crlf
        + N'    EXEC sp_serveroption @server = N''' + @ls_q + N''', @optname = N''connect timeout'', @optvalue = N''' + CAST(@ls_conn_to AS NVARCHAR(10)) + N''';' + @crlf
        + N'    EXEC sp_serveroption @server = N''' + @ls_q + N''', @optname = N''query timeout'', @optvalue = N''' + CAST(@ls_query_to AS NVARCHAR(10)) + N''';' + @crlf;

    -- sp_addlinkedserver always creates a default mapping for every login. Remove it if the source has none.
    IF @ls_default = 0
        SET @ddl = @ddl + @crlf
            + N'    -- No default mapping on the source: logins not listed below cannot connect ("Not be made")' + @crlf
            + N'    EXEC sp_droplinkedsrvlogin @rmtsrvname = N''' + @ls_q + N''', @locallogin = NULL;' + @crlf;

    -- Login mappings for this linked server
    DECLARE @ll_local   NVARCHAR(128);
    DECLARE @ll_remote  NVARCHAR(128);
    DECLARE @ll_useself bit;

    DECLARE ll_cur CURSOR LOCAL FAST_FORWARD FOR
        SELECT
            ISNULL(sp.name, N''),
            ISNULL(ll.remote_name, N''),
            ll.uses_self_credential
        FROM sys.linked_logins ll
        INNER JOIN sys.servers s2 ON ll.server_id = s2.server_id
        LEFT JOIN sys.server_principals sp ON ll.local_principal_id = sp.principal_id
        WHERE s2.name = @ls_name
        ORDER BY sp.name;

    OPEN ll_cur;
    FETCH NEXT FROM ll_cur INTO @ll_local, @ll_remote, @ll_useself;

    WHILE @@FETCH_STATUS = 0
    BEGIN
        SET @ddl = @ddl + @crlf
            + CASE
                WHEN @ll_useself = 1 THEN
                    CASE WHEN @ll_local <> N''
                         THEN N'    -- Impersonation mapping (self credentials) for one login' + @crlf
                         ELSE N'    -- Default for every other login: its own credentials ("Be made using the login''s current security context")' + @crlf END +
                    N'    EXEC sp_addlinkedsrvlogin' + @crlf +
                    N'        @rmtsrvname  = N''' + @ls_q + N''',' + @crlf +
                    N'        @useself     = N''True''' + @crlf +
                    CASE WHEN @ll_local <> N''
                         THEN N'       ,@locallogin = N''' + REPLACE(@ll_local, N'''', N'''''') + N'''' + @crlf
                         ELSE N'' END +
                    N'    ;' + @crlf
                WHEN @ll_remote <> N'' AND @ll_local = N'' THEN
                    N'    -- Catch-all mapping - ENTER_PASSWORD_HERE (stored credential, cannot be scripted)' + @crlf +
                    N'    EXEC sp_addlinkedsrvlogin' + @crlf +
                    N'        @rmtsrvname  = N''' + @ls_q + N''',' + @crlf +
                    N'        @useself     = N''False'',' + @crlf +
                    N'        @locallogin  = NULL,' + @crlf +
                    N'        @rmtuser     = N''' + REPLACE(@ll_remote, N'''', N'''''') + N''',' + @crlf +
                    N'        @rmtpassword = N''ENTER_PASSWORD_HERE'';' + @crlf
                WHEN @ll_remote <> N'' THEN
                    N'    -- Explicit mapping - ENTER_PASSWORD_HERE (stored credential, cannot be scripted)' + @crlf +
                    N'    EXEC sp_addlinkedsrvlogin' + @crlf +
                    N'        @rmtsrvname  = N''' + @ls_q + N''',' + @crlf +
                    N'        @useself     = N''False'',' + @crlf +
                    N'        @locallogin  = N''' + REPLACE(@ll_local,  N'''', N'''''') + N''',' + @crlf +
                    N'        @rmtuser     = N''' + REPLACE(@ll_remote, N'''', N'''''') + N''',' + @crlf +
                    N'        @rmtpassword = N''ENTER_PASSWORD_HERE'';' + @crlf
                WHEN @ll_local = N'' THEN
                    N'    -- Default for every other login: no security context ("Be made without using a security context")' + @crlf +
                    N'    EXEC sp_addlinkedsrvlogin' + @crlf +
                    N'        @rmtsrvname  = N''' + @ls_q + N''',' + @crlf +
                    N'        @useself     = N''False'',' + @crlf +
                    N'        @locallogin  = NULL,' + @crlf +
                    N'        @rmtuser     = NULL,' + @crlf +
                    N'        @rmtpassword = NULL;' + @crlf
                ELSE
                    N'    -- One login connects without a security context (no remote credentials)' + @crlf +
                    N'    EXEC sp_addlinkedsrvlogin' + @crlf +
                    N'        @rmtsrvname  = N''' + @ls_q + N''',' + @crlf +
                    N'        @useself     = N''False'',' + @crlf +
                    N'        @locallogin  = N''' + REPLACE(@ll_local, N'''', N'''''') + N''',' + @crlf +
                    N'        @rmtuser     = NULL,' + @crlf +
                    N'        @rmtpassword = NULL;' + @crlf
              END;

        FETCH NEXT FROM ll_cur INTO @ll_local, @ll_remote, @ll_useself;
    END

    CLOSE ll_cur;
    DEALLOCATE ll_cur;

    SET @ddl = @ddl + N'END' + @crlf + N'GO' + @crlf + @crlf;

    FETCH NEXT FROM ls_cur INTO
        @ls_name, @ls_product, @ls_provider, @ls_datasrc, @ls_location, @ls_provstr, @ls_catalog,
        @ls_rpc, @ls_rpc_out, @ls_data, @ls_collation, @ls_remcoll, @ls_lazy, @ls_promote,
        @ls_collname, @ls_conn_to, @ls_query_to, @ls_default;
END

CLOSE ls_cur;
DEALLOCATE ls_cur;

IF NOT EXISTS (SELECT 1 FROM sys.servers WHERE is_linked = 1)
    SET @ddl = @ddl + N'-- No linked servers found on ' + @@SERVERNAME + @crlf;

SELECT @ddl AS ddl;

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

# Set the source server for the session:
.\tools\local-sql\Set-SqlConnection.ps1 -ServerInstance PROD01\SQL2019

# Generate the DDL, review the output before running it on the target:
.\powershell\migration\Generate-LinkedServerScript.ps1

# Output: output-files\migration\*.sql

This script lives in the repo at:


Example Output

The whole script comes back as one value, so run it with results to text; in the grid you only see its first line. Long output can stop short, raise the maximum characters if it does. The capture is the end of the output on the lab instance, the block for REPORTING01:

Generate-LinkedServerScript in SSMS with results to text: the REPORTING01 block with every sp_serveroption written out and the default mapping for every other login

Every server option is written out, not only the ones that differ from a default, because sp_addlinkedserver starts each product with different defaults. The last call keeps the source’s default for every other login, here their own credentials. A source that refuses unlisted logins gets the opposite, sp_droplinkedsrvlogin @locallogin = NULL with a comment above it saying why.

To test the output, I set up four throwaway linked servers and ran their blocks back on the same instance under new names: one with a single mapped login and no access for anyone else, one with RPC Out on, data access off and timeouts of 30 and 120 seconds, one created the short way with @srvproduct = N'SQL Server', and one mapping a login to no remote user. Every block ran without a message. All four came back with the same provider, provider string, catalog and mappings, and the server options matched field for field. A login with no mapping on the first was refused on the copy as on the original, with Msg 7416.


What to Check in the Generated Script

Every ENTER_PASSWORD_HERE is replaced. Stored remote passwords cannot be read back out, so explicit mappings carry a placeholder. Left in, the call succeeds and stores that text as the password, and nothing fails until the first query through the linked server:

A generated linked server block run with ENTER_PASSWORD_HERE left in, then a query through it fails with Msg 18456, Login failed for user Lab1028_gls_rmt

Run that one sp_addlinkedsrvlogin call again with the real password and the next query goes through; it replaces the stored mapping.

The target did not already have one by that name. Each block sits inside IF NOT EXISTS, so a linked server already on the target is skipped with no message, and its options and mappings stay as they were. Running the lab blocks a second time changed nothing and said nothing.

The target has the logins the mappings name. A mapping for a local login the target does not have fails, so script the logins across first.


Best Practices

  • Always review the generated DDL before running it on the target. Nothing here executes automatically.
  • Replace every ENTER_PASSWORD_HERE placeholder in the output by hand. Stored remote credentials can’t be scripted.
  • Run this after the target’s own logins exist. A mapping for a login the target doesn’t have fails with Msg 15007 and the linked server is left without it (see Generate Login Script).
  • Test connectivity through each recreated linked server after migration. A linked server that creates without error can still fail its first real query if a mapping was missed.

MS Docs covers sys.servers, sys.linked_logins and sys.server_principals in full.


Frequently Asked Questions

Why can’t remote login passwords be scripted?

SQL Server never exposes stored linked server credentials in a readable form. They’re stored encrypted and only used internally at connection time, so the generator writes an ENTER_PASSWORD_HERE placeholder and a comment in their place.

What’s the difference between an impersonation mapping and an explicit login mapping?

Impersonation (uses_self_credential = 1) passes the local login’s own credentials through to the remote server, no password needed. An explicit mapping connects as a specific remote login with its own stored password, which is exactly the credential that can’t be scripted and needs ENTER_PASSWORD_HERE filled in by hand.

Why do linked server queries fail with Msg 18456 after running the script?

An ENTER_PASSWORD_HERE line was run as it stands. sp_addlinkedsrvlogin accepts it and stores that text as the remote password, so the linked server is created cleanly and the failure only shows up on the first query through it. Run that one sp_addlinkedsrvlogin call again with the real password; it replaces the stored mapping.

Why does the script need ALTER ANY LINKED SERVER?

Without it, provider_string in sys.servers reads NULL, so settings such as TrustServerCertificate=yes would drop out of the generated script. Without VIEW ANY DEFINITION as well, the logins named in each mapping cannot be seen. Rather than write that wrong script, it stops with Msg 50000 and says what it needs. sysadmin has both.

Why is there no local_login_name column in sys.linked_logins?

The view stores the local side as local_principal_id, with 0 meaning the mapping applies to every login, and the self-credential flag is uses_self_credential, singular. A query written against local_login_name or uses_self_credentials fails with Msg 207. Join local_principal_id to sys.server_principals to get the login name.


Summary

One generator, one job: recreate every linked server and its login mappings on the target. Check each block against the list above before you run it, and replace the password placeholders by hand. That part can’t be automated.


Related

Comments

Leave a Reply

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