Automate SharePoint List CRUD Operations
Using PowerShell, Microsoft Graph API
& GitHub Actions
App-only Auth with Certificate |
Production-Ready Pipeline
Introduction
If you are managing SharePoint Online at scale, you already
know that manual list operations are a bottleneck. This post walks you through
building a fully automated GitHub Actions pipeline that performs Create, Read,
Update, and Delete (CRUD) operations on SharePoint Lists using PowerShell and
the Microsoft Graph API — authenticated via a self-signed certificate with
app-only permissions.
No delegated auth. No stored passwords. Just a certificate
thumbprint, a GitHub Secret, and a workflow that runs on schedule or on-demand.
Architecture Overview
The diagram below shows the full pipeline — from GitHub
trigger through certificate-based JWT auth to SharePoint List CRUD via Graph
API.
Each step is driven by environment variables injected from
GitHub Secrets, keeping credentials fully out of source code. The certificate
private key never leaves the runner memory — it is loaded ephemerally via
X509KeyStorageFlags.EphemeralKeySet.
Prerequisites
• Azure
AD App Registration with Sites.ReadWrite.All (application permission)
• Self-signed
certificate (.pfx) uploaded to the App Registration
• GitHub
repository with Actions enabled
• PowerShell
7+ knowledge
• Microsoft.Graph
PowerShell SDK or raw Graph REST calls (this post uses REST)
|
NOTE |
App-only Graph API requires Sites.ReadWrite.All as an Application
permission (not Delegated). Admin consent is mandatory. |
Step 1 — Azure App Registration & Certificate Setup
1.1 Create the App Registration
In Azure Portal > App Registrations > New
Registration. Name it something like sp-crud-automation. Leave the Redirect URI
blank — this is a daemon app.
Under API Permissions, add:
• Microsoft
Graph > Application permissions > Sites.ReadWrite.All
• Grant
admin consent
1.2 Generate a Self-Signed
Certificate
Run this locally on your machine. The .pfx is stored in
GitHub Secrets; the public .cer is uploaded to the App Registration.
#
Generate self-signed cert (valid 2 years)
$cert
= New-SelfSignedCertificate `
-Subject 'CN=sp-crud-automation' `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-KeyExportPolicy Exportable `
-KeySpec Signature `
-KeyLength 2048 `
-HashAlgorithm SHA256 `
-NotAfter (Get-Date).AddYears(2)
#
Export public cert (.cer) for Azure App Registration
Export-Certificate
-Cert $cert -FilePath .\sp-crud.cer
#
Export private cert (.pfx) for GitHub Secret
$pwd
= ConvertTo-SecureString -String 'YourP@ssw0rd' -Force -AsPlainText
Export-PfxCertificate
-Cert $cert -FilePath .\sp-crud.pfx -Password $pwd
#
Convert .pfx to base64 string (paste this into GitHub Secret)
[Convert]::ToBase64String([IO.File]::ReadAllBytes('.\sp-crud.pfx'))
| clip
In Azure App Registration > Certificates & Secrets
> Certificates tab, upload sp-crud.cer. Note the Thumbprint — you will need
it as a GitHub Secret.
Step 2 — GitHub Secrets Configuration
Go to your repo > Settings > Secrets and variables
> Actions and create the following repository secrets:
|
Secret Name |
Value |
|
AZURE_TENANT_ID |
Your
Azure AD Tenant ID (GUID) |
|
AZURE_CLIENT_ID |
App
Registration Client ID (GUID) |
|
SP_CERT_BASE64 |
Base64-encoded
.pfx file content |
|
SP_CERT_PASSWORD |
Password
used when exporting the .pfx |
|
SP_SITE_URL |
https://yourtenant.sharepoint.com/sites/yoursite |
|
SP_LIST_NAME |
Name
of the SharePoint list |
|
TIP |
Use environment-scoped secrets in GitHub for multi-environment
setups (dev / staging / prod) to keep credentials isolated. |
Step 3 — PowerShell CRUD Script
Create a file at scripts/sp-list-crud.ps1. This script
authenticates using the certificate, acquires a Graph API token, and performs
all four CRUD operations.
3.1 Authentication — Get-GraphToken
function
Get-GraphToken {
param(
[string]$TenantId,
[string]$ClientId,
[string]$CertBase64,
[string]$CertPassword
)
# Load cert from base64
$certBytes
= [Convert]::FromBase64String($CertBase64)
$certObject =
[System.Security.Cryptography.X509Certificates.X509Certificate2]::new(
$certBytes,
$CertPassword,
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
)
# Build JWT header + payload
$now
= [DateTimeOffset]::UtcNow
$header
= [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((
@{ alg = 'RS256'; typ = 'JWT';
x5t =
[Convert]::ToBase64String($certObject.GetCertHash()) }
| ConvertTo-Json -Compress)))
$payload =
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((
@{ aud =
"https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token";
iss = $ClientId; sub =
$ClientId;
jti =
[Guid]::NewGuid().ToString();
nbf =
$now.ToUnixTimeSeconds();
exp =
$now.AddMinutes(10).ToUnixTimeSeconds() }
| ConvertTo-Json -Compress)))
# Sign JWT
$unsignedJwt = "$header.$payload"
$rsa = $certObject.GetRSAPrivateKey()
$sigBytes = $rsa.SignData(
[Text.Encoding]::UTF8.GetBytes($unsignedJwt),
[Security.Cryptography.HashAlgorithmName]::SHA256,
[Security.Cryptography.RSASignaturePadding]::Pkcs1)
$signature = [Convert]::ToBase64String($sigBytes)
$clientAssertion =
"$unsignedJwt.$signature"
# Exchange for access token
$body = @{
grant_type = 'client_credentials'
client_id = $ClientId
client_assertion_type =
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
client_assertion = $clientAssertion
scope =
'https://graph.microsoft.com/.default'
}
$tokenResp = Invoke-RestMethod -Method Post
`
-Uri
"https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
-Body $body -ContentType
'application/x-www-form-urlencoded'
return $tokenResp.access_token
}
3.2 Get Site & List IDs
function
Get-SPSiteAndListId {
param([string]$Token, [string]$SiteUrl,
[string]$ListName)
# Extract host + path from URL
$uri
= [Uri]$SiteUrl
$hostname = $uri.Host
$sitePath =
$uri.AbsolutePath.TrimStart('/')
# Resolve site ID
$siteResp = Invoke-RestMethod `
-Uri
"https://graph.microsoft.com/v1.0/sites/$hostname`:/$sitePath" `
-Headers @{ Authorization =
"Bearer $Token" }
$siteId = $siteResp.id
# Resolve list ID
$listResp = Invoke-RestMethod `
-Uri
"https://graph.microsoft.com/v1.0/sites/$siteId/lists?`$filter=displayName
eq '$ListName'" `
-Headers @{ Authorization =
"Bearer $Token" }
$listId = $listResp.value[0].id
return @{ SiteId = $siteId; ListId =
$listId }
}
3.3 CREATE — Add a List Item
function
New-SPListItem {
param(
[string]$Token,
[string]$SiteId,
[string]$ListId,
[hashtable]$Fields # column name => value pairs
)
$body = @{ fields = $Fields } |
ConvertTo-Json -Depth 5
$item = Invoke-RestMethod `
-Method Post `
-Uri
"https://graph.microsoft.com/v1.0/sites/$SiteId/lists/$ListId/items"
`
-Headers @{ Authorization =
"Bearer $Token"; 'Content-Type' = 'application/json' } `
-Body $body
Write-Host "[CREATE] Item created. ID:
$($item.id)"
return $item
}
3.4 READ — Query List Items
function
Get-SPListItems {
param(
[string]$Token,
[string]$SiteId,
[string]$ListId,
[string]$Filter = '' # OData filter e.g. "fields/Status eq
'Active'"
)
$url =
"https://graph.microsoft.com/v1.0/sites/$SiteId/lists/$ListId/items?expand=fields"
if ($Filter) { $url +=
"&`$filter=$Filter" }
$items = @()
do {
$resp
= Invoke-RestMethod -Uri $url -Headers @{ Authorization = "Bearer
$Token" }
$items += $resp.value
$url
= $resp.'@odata.nextLink'
} while ($url)
Write-Host "[READ] Retrieved
$($items.Count) item(s)."
return $items
}
3.5 UPDATE — Patch a List Item
function
Set-SPListItem {
param(
[string]$Token,
[string]$SiteId,
[string]$ListId,
[string]$ItemId,
[hashtable]$Fields
)
$body = $Fields | ConvertTo-Json -Depth 5
Invoke-RestMethod `
-Method Patch `
-Uri
"https://graph.microsoft.com/v1.0/sites/$SiteId/lists/$ListId/items/$ItemId/fields"
`
-Headers @{ Authorization =
"Bearer $Token"; 'Content-Type' = 'application/json' } `
-Body $body | Out-Null
Write-Host "[UPDATE] Item $ItemId
updated."
}
3.6 DELETE — Remove a List Item
function
Remove-SPListItem {
param(
[string]$Token,
[string]$SiteId,
[string]$ListId,
[string]$ItemId
)
Invoke-RestMethod `
-Method Delete `
-Uri
"https://graph.microsoft.com/v1.0/sites/$SiteId/lists/$ListId/items/$ItemId"
`
-Headers @{ Authorization =
"Bearer $Token" } | Out-Null
Write-Host "[DELETE] Item $ItemId
deleted."
}
3.7 Main Entrypoint
#
── Entrypoint ──────────────────────────────────────────────────────────
param(
[string]$Operation = $env:SP_OPERATION, # CREATE | READ | UPDATE | DELETE
[string]$ItemId = $env:SP_ITEM_ID,
[string]$FieldsJson = $env:SP_FIELDS_JSON # '{ "Title": "Test",
"Status": "Active" }'
)
$token
= Get-GraphToken `
-TenantId
$env:AZURE_TENANT_ID `
-ClientId
$env:AZURE_CLIENT_ID `
-CertBase64
$env:SP_CERT_BASE64 `
-CertPassword $env:SP_CERT_PASSWORD
$ids
= Get-SPSiteAndListId -Token $token -SiteUrl $env:SP_SITE_URL -ListName
$env:SP_LIST_NAME
switch
($Operation.ToUpper()) {
'CREATE' {
$fields = $FieldsJson |
ConvertFrom-Json -AsHashtable
New-SPListItem -Token $token -SiteId
$ids.SiteId -ListId $ids.ListId -Fields $fields
}
'READ' {
$items = Get-SPListItems -Token $token
-SiteId $ids.SiteId -ListId $ids.ListId
$items | ForEach-Object { $_.fields |
ConvertTo-Json -Compress }
}
'UPDATE' {
$fields = $FieldsJson |
ConvertFrom-Json -AsHashtable
Set-SPListItem -Token $token -SiteId
$ids.SiteId -ListId $ids.ListId -ItemId $ItemId -Fields $fields
}
'DELETE' {
Remove-SPListItem -Token $token -SiteId
$ids.SiteId -ListId $ids.ListId -ItemId $ItemId
}
default { Write-Error "Unknown
operation: $Operation"; exit 1 }
}
Step 4 — GitHub Actions Workflow
Create .github/workflows/sp-crud.yml. The workflow uses
workflow_dispatch inputs so you can trigger any CRUD operation manually from
the GitHub UI, or wire it into your automation chain.
name:
SharePoint List CRUD via Graph API
on:
workflow_dispatch:
inputs:
operation:
description: 'CRUD Operation'
required: true
type: choice
options: [CREATE, READ, UPDATE, DELETE]
item_id:
description: 'List Item ID (required
for UPDATE / DELETE)'
required: false
fields_json:
description: 'JSON string of fields
(required for CREATE / UPDATE)'
required: false
default:
'{"Title":"Automated
Item","Status":"Active"}'
schedule:
- cron: '0 2 * * 1' # Every Monday 02:00 UTC (07:30 IST)
permissions:
contents: read
jobs:
sp-crud:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run SharePoint CRUD Script
shell: pwsh
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
SP_CERT_BASE64: ${{ secrets.SP_CERT_BASE64 }}
SP_CERT_PASSWORD: ${{ secrets.SP_CERT_PASSWORD }}
SP_SITE_URL: ${{ secrets.SP_SITE_URL }}
SP_LIST_NAME: ${{ secrets.SP_LIST_NAME }}
SP_OPERATION: ${{ github.event.inputs.operation ||
'READ' }}
SP_ITEM_ID: ${{ github.event.inputs.item_id }}
SP_FIELDS_JSON: ${{ github.event.inputs.fields_json }}
run: |
./scripts/sp-list-crud.ps1
|
IST NOTE |
The cron 0 2 * * 1 fires at 02:00 UTC = 07:30 IST. Adjust based
on your team's timezone. GitHub runners may have up to ~5 min delay on shared
infrastructure. |
Step 5 — How It All Connects
|
Component |
Role |
|
Azure
App Registration |
Identity
— client ID + cert-based assertion |
|
Self-Signed
Certificate |
Replaces
client secret; no expiry risk if rotated on schedule |
|
GitHub
Secrets |
Secure
vault for tenant/client/cert values |
|
Graph
API v1.0 |
SharePoint
list operations via /sites/{id}/lists/{id}/items |
|
PowerShell
7 (pwsh) |
Cross-platform
script runner on ubuntu-latest |
|
workflow_dispatch |
On-demand
trigger with typed inputs from GitHub UI |
Common Issues & Fixes
|
Error |
Likely Cause |
Fix |
|
403
Forbidden |
Missing
admin consent |
Grant
admin consent in Azure Portal |
|
InvalidClientSecret |
Wrong
cert loaded |
Verify
SP_CERT_BASE64 is the .pfx, not the .cer |
|
List
not found |
displayName
mismatch |
Check
SP_LIST_NAME matches exactly (case-sensitive) |
|
AADSTS700027 |
Cert
thumbprint mismatch |
Re-upload
.cer to App Registration and re-export .pfx |
|
nextLink
loop |
Large
list pagination |
Already
handled in Get-SPListItems with do/while |
Security Best Practices
• Rotate
certificates annually — add a calendar reminder or automate via Azure Key Vault
• Scope
permissions tightly: prefer Sites.Selected over Sites.ReadWrite.All for prod
• Use
GitHub environment protection rules to require approval before prod runs
• Never
log $Token or $CertBase64 — add ::add-mask:: if you echo dynamic values
• Store
the .pfx passphrase in a separate secret from the cert itself
Wrap Up
You now have a fully headless, cert-authenticated GitHub
Actions pipeline that covers the complete SharePoint List CRUD lifecycle via
Microsoft Graph API. No PnP module overhead, no stored passwords, no delegated
sessions — just a clean PowerShell script, a certificate, and a workflow YAML.
From here you can extend this by adding error handling with
try/catch, posting results to a Teams channel via Incoming Webhook, or chaining
workflows to trigger downstream automation after list updates.
Drop a comment if you hit any issues with the JWT assertion
or Graph pagination — happy to dig into it.
#SharePoint
#PowerShell #GitHubActions #MicrosoftGraph #M365
#PowerPlatform #Azure