Lately I’ve been focussing a lot on PowerShell related blogs and topics, so I thought it would be a good moment to go back to some Azure stuff again (and of course with a touch of PowerShell πŸ˜‰). So here it is! My latest contribution which hopefully will help you strengthen your security whenever you are using databases with front-end services!

If you’ve been following my blog, you know I’m passionate about event-driven architecture and serverless solutions. Today, we’re diving into something equally important: passwordless authentication for databases! πŸ”In modern cloud environments, storing database credentials in connection strings is a security risk.

Passwords can be leaked, stolen, or accidentally committed to source control. What if I told you that you can connect to Azure SQL Database without storing a single password?In this blog, we’ll explore how to use Azure Managed Identity with PowerShell Azure Functions to securely access Azure SQL Database – no connection strings, no passwords, just pure Azure AD token-based authentication!

Time for some serverless security automation!

During this post, you’ll see this icon πŸŽ¬, which indicates that action is required so you get the full benefit out of this post.And I’ve introduced the πŸ“’, which indicates that the subsequent part explains the technical details in the context of this post.

When to utilize this

Why go through the effort of setting up managed identity when connection strings “just work”? Well… connection strings do work, but they come with significant security challenges:

πŸ”΄ Credentials in code/config – Easy to leak or commit to repositories

πŸ”΄ Manual rotation – Requires updating all applications when passwords change

πŸ”΄ Broad access – Connection strings often have more permissions than needed

πŸ”΄ No audit trail – Hard to track which identity accessed what

Managed Identity solves all of this:

βœ… No credentials stored – Azure AD handles authentication

βœ… Automatic token rotation – Azure manages the lifecycle

βœ… Granular permissions – Each function gets only what it needs

βœ… Full audit trail – Every database access is logged with the identity

🧠 And! This is a practical example of implementing zero-trust architecture in Azure. If you’re preparing for production workloads or want to follow security best practices, this is the way forward!

πŸ“’ Use cases: This pattern is perfect for web APIs, background processing functions, data migration tools, reporting services, or any serverless workload that needs database access.

πŸ’‘ In this blog I’ll be using Azure functions, but you can use whatever type of resource which supports managed identity and somehow can query a SQL server for this πŸ˜‰

The Architecture

Here’s what we’re building:

What gets deployed:

  • Azure SQL Server with Azure AD authentication
  • SQL Database with sample data
  • Azure Function App (PowerShell 7.4) with managed identity
  • Storage Account for function runtime
  • Application Insights for monitoring
  • CORS pre-configured for Azure Portal testing

Setting up the Azure components

You have two options: deploy manually or let me handle it for you. I’ll provide both!

Option 1: Let me deploy it for you πŸš€

Deploy to Azure

When deploying, you’ll need to provide:

  • SQL Server Name – Globally unique (e.g., mycompany-sql-prod)
  • SQL Admin Username – For initial setup only
  • SQL Admin Password – Strong password (8+ chars, mixed case, numbers, symbols)
  • Function App Name – Globally unique (e.g., mycompany-func-prod)
  • Storage Account Name – Globally unique, lowercase, no hyphens (e.g., mycompanystorage123)

πŸ“’ The SQL admin credentials are only used for initial setup and emergency access. The function will never use these credentials, it uses managed identity tokens instead!

Don’t forget the name of the function! You need it later on

Example:

Option 2: Manual deployment

If you prefer manual deployment, create:

  • Azure SQL Server (with firewall rule for Azure services)
  • SQL Database
  • Function App (with system-assigned managed identity enabled)
  • Storage Account
  • Application Insights

Oh, and don’t forget to configure CORS for portal.azure.com so that you can test in the end your configuration!

Configuring Azure AD Authentication

Here’s where the magic happens! We need to enable Azure AD authentication on SQL Server.

🎬 Step 1: Set an Azure AD Administrator on the SQL Server

  1. Go to Azure Portal
  2. Navigate to SQL servers β†’ [Your SQL Server]
  3. Click Microsoft Entra ID in the left menu
  4. Click Set admin at the top
  5. Search for your user account
  6. Select it and click Select
  7. Set β€˜Support only Microsoft Entra Authentication for this server”
  8. Click Save

πŸ“’ This step enables Azure AD authentication on the SQL Server. Without this, the server won’t accept Azure AD tokens, only SQL authentication!

Granting Database Access to Managed Identity

Now comes the critical step: giving the Function App’s managed identity permission to access the database.

🎬 Step 2: Connect to the Database

  1. Go to SQL databases β†’ [Your Database]
  2. Click Query editor in the left menu
  3. Login using Azure Active Directory authentication
  4. You’ll be connected as the Azure AD admin

🎬 Run the query below

CREATE USER [your-function-app-name] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [your-function-app-name];
ALTER ROLE db_datawriter ADD MEMBER [your-function-app-name];
SELECT 
    name AS UserName,
    type_desc AS UserType,
    authentication_type_desc AS AuthType,
    create_date AS CreatedDate
FROM sys.database_principals 
WHERE name = 'your-function-app-name';

πŸ“’ What’s happening here?

  • CREATE USER […] FROM EXTERNAL PROVIDER creates a database user linked to the Azure AD managed identity
  • ALTER ROLE grants specific permissions (read and write)
  • The user name must exactly match the Function App name
  • This must be done in the database context, not master!

🎬 Step 3: Create Sample Table While you’re connected, let’s create a sample table to test with:

🎬 Run the query below in the same query editor

CREATE TABLE Users (
    Id INT IDENTITY(1,1) PRIMARY KEY,
    Name NVARCHAR(100) NOT NULL,
    Email NVARCHAR(255) NOT NULL UNIQUE,
    CreatedDate DATETIME2 DEFAULT GETDATE()
);
CREATE INDEX IX_Users_Email ON Users(Email);
CREATE INDEX IX_Users_CreatedDate ON Users(CreatedDate);

INSERT INTO Users (Name, Email) VALUES 
    ('John Doe', 'john.doe@bartpasmans.tech'),
    ('Jane Smith', 'jane.smith@bartpasmans.tech'),
    ('Bob Johnson', 'bob.johnson@bartpasmans.tech'),
    ('Alice Williams', 'alice.williams@bartpasmans.tech'),
    ('Charlie Brown', 'charlie.brown@bartpasmans.tech');

SELECT * FROM Users;

Deploy the function code

The ARM template deployed the infrastructure (or you did this step manually), but we need to deploy the function code separately.

🎬 Deploy the Function CodeMethod 1: Via Azure Portal (Quick testing)

  1. Go to your Function App β†’ Functions β†’ Create
  2. Choose HTTP trigger
  3. Name: DatabaseQuery
  4. Authorization: Function
  5. Click Create
  6. Go to Code + Test
  7. Copy the code below and add it to the function
  8. Paste and save
using namespace System.Net

param($Request, $TriggerMetadata)

Write-Host "πŸ”’ Database Query Function Started"
Write-Host "πŸ“… Timestamp: $(Get-Date)"
Write-Host "πŸ†” Invocation ID: $($TriggerMetadata.InvocationId)"

$sqlServerName = $env:SqlServerName
$sqlDatabaseName = $env:SqlDatabaseName

Write-Host "πŸ—„οΈ SQL Server: $sqlServerName"
Write-Host "πŸ’Ύ Database: $sqlDatabaseName"

$body = @{
    success = $false
    message = ""
    data = $null
    timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
}

try {
    Write-Host "βœ… Using built-in System.Data.SqlClient for database connectivity"
    Write-Host "πŸ”‘ Requesting Azure AD access token for SQL Database..."
    
    $resourceURI = "https://database.windows.net/"
    $tokenAuthURI = $env:IDENTITY_ENDPOINT + "?resource=$resourceURI&api-version=2019-08-01"
    $tokenResponse = Invoke-RestMethod -Method Get -Headers @{"X-IDENTITY-HEADER"=$env:IDENTITY_HEADER} -Uri $tokenAuthURI
    $accessToken = $tokenResponse.access_token
    
    if ($accessToken) {
        Write-Host "βœ… Access token obtained successfully"
    } else {
        throw "Failed to obtain access token from managed identity"
    }
    
    $action = $Request.Query.action
    if (-not $action) {
        $action = $Request.Body.action
    }
    if (-not $action) {
        $action = "read" 
    }

    Write-Host "βš™οΈ Action: $action"

   $connectionString = "Server=$sqlServerName; Database=$sqlDatabaseName; Encrypt=True; TrustServerCertificate=False; Connection Timeout=30;"
    
    Write-Host "πŸ”Œ Connecting to database using managed identity..."
    
    $connection = New-Object System.Data.SqlClient.SqlConnection
    $connection.ConnectionString = $connectionString
    $connection.AccessToken = $accessToken
    
    $connection.Open()
    Write-Host "βœ… Database connection established"

    $command = $connection.CreateCommand()
    
    switch ($action.ToLower()) {
        "read" {
            Write-Host "πŸ“– Executing SELECT query..."
            $command.CommandText = "SELECT TOP 10 Id, Name, Email, CreatedDate FROM Users ORDER BY CreatedDate DESC"
            
            $adapter = New-Object System.Data.SqlClient.SqlDataAdapter($command)
            $dataSet = New-Object System.Data.DataSet
            $adapter.Fill($dataSet) | Out-Null
            
            $results = @()
            foreach ($row in $dataSet.Tables[0].Rows) {
                $results += @{
                    Id = $row["Id"]
                    Name = $row["Name"]
                    Email = $row["Email"]
                    CreatedDate = $row["CreatedDate"].ToString("yyyy-MM-dd HH:mm:ss")
                }
            }
            
            $body.data = $results
            $body.message = "Successfully retrieved $($results.Count) records"
            Write-Host "βœ… Retrieved $($results.Count) records"
        }
        
        "write" {        
            $name = $Request.Query.name
            $email = $Request.Query.email
            if (-not $name) { $name = $Request.Body.name }
            if (-not $email) { $email = $Request.Body.email }
            
            if (-not $name -or -not $email) {
                throw "Name and email are required for write operation"
            }
            
            Write-Host "✍️ Executing INSERT query..."
            Write-Host "  Name: $name"
            Write-Host "  Email: $email"
            
            $command.CommandText = "INSERT INTO Users (Name, Email) VALUES (@Name, @Email); SELECT SCOPE_IDENTITY() AS NewId"
            $command.Parameters.AddWithValue("@Name", $name) | Out-Null
            $command.Parameters.AddWithValue("@Email", $email) | Out-Null
            
            $newId = $command.ExecuteScalar()
            
            $body.data = @{
                Id = [int]$newId
                Name = $name
                Email = $email
            }
            $body.message = "Successfully inserted new record with ID: $newId"
            Write-Host "βœ… Inserted new record with ID: $newId"
        }
        
        "update" {           
            $id = $Request.Query.id
            $name = $Request.Query.name
            $email = $Request.Query.email
            if (-not $id) { $id = $Request.Body.id }
            if (-not $name) { $name = $Request.Body.name }
            if (-not $email) { $email = $Request.Body.email }
            
            if (-not $id) {
                throw "ID is required for update operation"
            }
            
            Write-Host "πŸ”„ Executing UPDATE query..."
            Write-Host "  ID: $id"
            
            $updateParts = @()
            if ($name) { 
                $updateParts += "Name = @Name"
                $command.Parameters.AddWithValue("@Name", $name) | Out-Null
            }
            if ($email) { 
                $updateParts += "Email = @Email"
                $command.Parameters.AddWithValue("@Email", $email) | Out-Null
            }
            
            if ($updateParts.Count -eq 0) {
                throw "At least one field (name or email) must be provided for update"
            }
            
            $command.CommandText = "UPDATE Users SET $($updateParts -join ', ') WHERE Id = @Id"
            $command.Parameters.AddWithValue("@Id", [int]$id) | Out-Null
            
            $rowsAffected = $command.ExecuteNonQuery()
            
            $body.data = @{
                Id = [int]$id
                RowsAffected = $rowsAffected
            }
            $body.message = "Successfully updated $rowsAffected record(s)"
            Write-Host "βœ… Updated $rowsAffected record(s)"
        }
        
        "count" {
            Write-Host "πŸ“Š Executing COUNT query..."
            $command.CommandText = "SELECT COUNT(*) as TotalUsers FROM Users"
            
            $count = $command.ExecuteScalar()
            
            $body.data = @{
                TotalUsers = [int]$count
            }
            $body.message = "Successfully retrieved count"
            Write-Host "βœ… Total users: $count"
        }
        
        default {
            throw "Invalid action: $action. Supported actions: read, write, update, count"
        }
    }
    
    $connection.Close()
    Write-Host "πŸ”Œ Database connection closed"
    
    $body.success = $true
    $statusCode = [HttpStatusCode]::OK

} catch {
    Write-Error "❌ Error occurred: $($_.Exception.Message)"
    Write-Error "Stack Trace: $($_.Exception.StackTrace)"
    
    $body.success = $false
    $body.message = "Error: $($_.Exception.Message)"
    $body.error = @{
        type = $_.Exception.GetType().FullName
        message = $_.Exception.Message
        stackTrace = $_.Exception.StackTrace
    }
    $statusCode = [HttpStatusCode]::InternalServerError
}

Write-Host "🏁 Function execution completed"
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = $statusCode
    Body = $body | ConvertTo-Json -Depth 10
    Headers = @{
        "Content-Type" = "application/json"
    }
})

How the Authentication Works

Let’s peek under the hood at what’s happening:

# 1. Function requests Azure AD token for SQL Database
$resourceURI = "https://database.windows.net/"
$tokenAuthURI = $env:IDENTITY_ENDPOINT + "?resource=$resourceURI&api-version=2019-08-01"
$tokenResponse = Invoke-RestMethod -Method Get -Headers @{"X-IDENTITY-HEADER"=$env:IDENTITY_HEADER} -Uri $tokenAuthURI
$accessToken = $tokenResponse.access_token

# 2. Create SQL connection without password
$connectionString = "Server=$sqlServerName; Database=$sqlDatabaseName; Encrypt=True;"
$connection = New-Object System.Data.SqlClient.SqlConnection
$connection.ConnectionString = $connectionString
$connection.AccessToken = $accessToken  # Token instead of password!

# 3. Open connection and execute queries
$connection.Open()
# Now you can run queries with the granted permissions

πŸ“’ The flow:

  1. Function starts and requests a token from Azure AD
  2. Azure AD validates the managed identity and issues a time-limited token
  3. Function connects to SQL Database using the token
  4. SQL Server validates the token and checks database permissions
  5. Query executes with the granted permissions (read/write)
  6. Connection closes, token expires automatically

No passwords anywhere! πŸŽ‰

Let’s Test!

Time to see it in action!🎬 Test in Azure Portal 

  1. Go to your Function App β†’ Functions β†’ DatabaseQuery
  2. Click Test/Run tab
  3. Set logs to file system:
  1. Click Run
  • You should see the result:
  • Database result:

Summary

That’s a wrap! πŸŽ‰In this blog, we explored how to implement passwordless database authentication using Azure Managed Identity with PowerShell Functions, the secure, serverless way!

We kicked things off by discussing why connection strings are a security risk and how managed identity solves these problems with zero-trust architecture.

Then we rolled up our sleeves and:

βœ… Deployed Azure SQL Server with Azure AD authentication enabled

βœ… Created a Function App with system-assigned managed identity

βœ… Granted granular database permissions (read/write) to the managed identity

βœ… Deployed a PowerShell function that connects using Azure AD tokens

βœ… Tested the solution with multiple database operations

🎬 If you followed along step-by-step, you now have a production-ready, secure, passwordless database access pattern running in Azure.

πŸ“’ This post is all about implementing security best practices without adding complexity. No credentials to manage, automatic token rotation, full audit trail, and granular permissions, all handled by Azure!

Key Takeaways:

  • πŸ”’ No passwords stored – Azure AD handles everything
  • ⚑ Lightweight design – Uses built-in .NET classes
  • πŸš€ Fast cold starts – Optimized for Consumption plan
  • πŸ“Š Full audit trail – Every access logged with identity
  • 🎯 CORS pre-configured – Test directly in Azure Portal

Thanks again for reading!

I hope this inspires you to implement managed identity in your own projects. Maybe next time you’ll add retry logic, connection pooling, or even integrate with Azure Key Vault for additional secrets!

Let me know what you build! πŸš€

Leave a Reply

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