Automating Microsoft Teams Provisioning with PowerShell and Graph API

The Challenge of Teams Sprawl When organizations adopt Microsoft Teams, they often face a common challenge: how to provision Teams consistently while maintaining security standards. Manual creation leads to inconsistent settings, security gaps, and administrative overhead. This guide shows how to automate Teams provisioning with PowerShell to ensure every team follows your organization’s security baseline. Prerequisites # Install required modules Install-Module Microsoft.Graph -Scope CurrentUser Install-Module MicrosoftTeams -Scope CurrentUser # Import modules Import-Module Microsoft.Graph Import-Module MicrosoftTeams Authentication Setup # Connect to Microsoft Graph Connect-MgGraph -Scopes "Group.ReadWrite.All", "TeamSettings.ReadWrite.All", "Directory.ReadWrite.All" # Connect to Teams PowerShell Connect-MicrosoftTeams The Automation Script Core Team Creation Function Example function New-StandardizedTeam { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string]$TeamName, [Parameter(Mandatory=$true)] [string]$Description, [Parameter(Mandatory=$false)] [string[]]$Owners = @(), [Parameter(Mandatory=$false)] [string[]]$Members = @() ) try { Write-Host "Creating team: $TeamName" -ForegroundColor Yellow # Create the Microsoft 365 Group first $groupParams = @{ displayName = $TeamName description = $Description mailEnabled = $true mailNickname = ($TeamName -replace '[^a-zA-Z0-9]', '') securityEnabled = $false groupTypes = @("Unified") visibility = "Private" } $group = New-MgGroup -BodyParameter $groupParams Write-Host "Group created with ID: $($group.Id)" -ForegroundColor Green # Wait for group provisioning Start-Sleep -Seconds 10 # Convert to Team $teamParams = @{ "memberSettings" = @{ "allowCreateUpdateChannels" = $false "allowDeleteChannels" = $false "allowCreatePrivateChannels" = $false } "guestSettings" = @{ "allowCreateUpdateChannels" = $false "allowDeleteChannels" = $false } "messagingSettings" = @{ "allowUserEditMessages" = $true "allowUserDeleteMessages" = $false "allowTeamMentions" = $true "allowChannelMentions" = $true } "funSettings" = @{ "allowGiphy" = $true "giphyContentRating" = "strict" "allowStickersAndMemes" = $false "allowCustomMemes" = $false } } $team = New-MgTeam -GroupId $group.Id -BodyParameter $teamParams Write-Host "Team provisioned successfully" -ForegroundColor Green # Configure General channel Set-TeamChannelModerators -GroupId $group.Id # Add owners and members Add-TeamMembers -GroupId $group.Id -Owners $Owners -Members $Members # Hide from GAL Update-MgGroup -GroupId $group.Id -HideFromAddressLists return @{ Success = $true GroupId = $group.Id TeamName = $TeamName } } catch { Write-Error "Failed to create team: $_" return @{ Success = $false Error = $_.Exception.Message } } } Channel Configuration function Set-TeamChannelModerators { param( [Parameter(Mandatory=$true)] [string]$GroupId ) # Get the General channel $channels = Get-MgTeamChannel -TeamId $GroupId $generalChannel = $channels | Where-Object { $_.DisplayName -eq "General" } if ($generalChannel) { # Update channel to require moderation $channelParams = @{ moderationSettings = @{ userNewMessageRestriction = "everyoneExceptGuests" replyRestriction = "everyone" allowNewMessageFromBots = $true allowNewMessageFromConnectors = $true } } Update-MgTeamChannel -TeamId $GroupId -ChannelId $generalChannel.Id -BodyParameter $channelParams Write-Host "General channel moderation configured" -ForegroundColor Green } } Bulk Provisioning function Import-TeamsFromCSV { param( [Parameter(Mandatory=$true)] [string]$CsvPath ) $teams = Import-Csv -Path $CsvPath $results = @() foreach ($team in $teams) { $result = New-StandardizedTeam ` -TeamName $team.TeamName ` -Description $team.Description ` -Owners ($team.Owners -split ';') ` -Members ($team.Members -split ';') $results += $result # Rate limiting Start-Sleep -Seconds 5 } # Export results $results | Export-Csv -Path "TeamsProvisioningResults.csv" -NoTypeInformation # Summary $successful = ($results | Where-Object { $_.Success -eq $true }).Count $failed = ($results | Where-Object { $_.Success -eq $false }).Count Write-Host "Provisioning Summary:" -ForegroundColor Cyan Write-Host "Successful: $successful" -ForegroundColor Green Write-Host "Failed: $failed" -ForegroundColor Red } CSV Template TeamName,Description,Owners,Members "Marketing Team","Marketing collaboration space","[email protected];[email protected]","[email protected];[email protected]" "Sales Team","Sales collaboration space","[email protected];[email protected]","[email protected];[email protected]" Advanced Configuration Apply Organization-Wide Settings function Set-OrganizationTeamsDefaults { # Get current policies $messagingPolicy = Get-CsTeamsMessagingPolicy -Identity Global # Update messaging policy Set-CsTeamsMessagingPolicy -Identity Global ` -AllowGiphy $true ` -GiphyRatingType "Strict" ` -AllowMemes $false ` -AllowStickers $false ` -AllowUserDeleteMessages $false ` -AllowUserEditMessages $true # Guest access configuration Set-CsTeamsGuestMessagingConfiguration ` -AllowGiphy $false ` -AllowMemes $false ` -AllowStickers $false ` -AllowUserDeleteMessages $false ` -AllowUserEditMessages $false Write-Host "Organization defaults applied" -ForegroundColor Green } Error Handling and Logging function Write-TeamsLog { param( [string]$Message, [string]$Level = "Info" ) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $logEntry = "$timestamp [$Level] $Message" Add-Content -Path "TeamsProvisioning.log" -Value $logEntry switch ($Level) { "Error" { Write-Host $Message -ForegroundColor Red } "Warning" { Write-Host $Message -ForegroundColor Yellow } "Success" { Write-Host $Message -ForegroundColor Green } default { Write-Host $Message } } } Best Practices Rate Limiting: Microsoft Graph has throttling limits. Add delays between API calls. Error Recovery: Implement retry logic for transient failures. Validation: Verify team settings post-creation. Documentation: Maintain a log of all provisioned teams. Regular Audits: Schedule periodic reviews of team settings. Conclusion Automating Teams provisioning ensures consistency, improves security posture, and reduces administrative overhead. This approach scales from small deployments to enterprise-wide migrations while maintaining your organization’s governance standards. ...

April 3, 2026 · 4 min · Me

Google Workspace to Microsoft 365 Migration: Two Weeks of Pilot Migration Stories

Two weeks into a Google Workspace to Microsoft 365 pilot migration for a mid-size org. Thirteen mailboxes in the pilot batch. What should have been a straightforward cutover turned into a deep education in Exchange migration edge cases, MRS throttling behavior, and the gap between what Microsoft documents and what actually happens in production. The Setup The architecture: phased cutover with Mimecast coexistence routing, GAM-managed forwarding during the transition window, and batch migration via Exchange Online migration endpoints backed by a GCP service account. A verified alias subdomain (o365.[domain]) serves as the target delivery domain, with workspace.[domain] as the coexistence routing domain pointing back to Google. ...

March 24, 2026 · 8 min · Zac

What a Google Workspace to M365 Migration Actually Looks Like Mid-Flight

Most migration guides describe the happy path. This isn’t that. This is what you actually find when you inherit a migration in progress and start pulling on threads. The Setup Mid-flight GWS to M365 migration. Test batches already running. Domain setup incomplete. Multiple migration endpoints in Exchange Online pointing at a GCP service account that nobody can confirm ownership of. This is a fairly normal state for an org-scale migration that started before all the architecture decisions were locked in. ...

March 11, 2026 · 6 min · Zac Lohrenz

TextEdit Will Corrupt Your Shell Commands and Lie to Your Face About It

I spent an embarrassing amount of time today chasing a dquote> prompt in zsh. Not because the commands were wrong. Not because of hidden characters in the source files. Not because of clipboard managers, bracketed paste mode, or terminal emulator quirks. Because TextEdit, Apple’s own plain text editor, was silently replacing a closing ASCII double quote with a Unicode smart quote, and doing it inconsistently enough to make the problem look like something else entirely. ...

February 20, 2026 · 5 min · Zac Lohrenz

Azure Key Vault: Migrating from Access Policies to RBAC Before the 2027 Deadline

Microsoft sent an email this week that caught my attention: all Azure Key Vault API versions prior to 2026-02-01 retire on February 27, 2027. The new API makes Azure RBAC the default access control model for Key Vaults, and legacy access policies become an explicit opt-in. Time to migrate before the deadline forces your hand. The Change: What Microsoft Is Actually Doing Starting with API version 2026-02-01 (releasing February 2026): ...

February 5, 2026 · 9 min · Zac Lohrenz