Saturday, March 14, 2026

Automate SharePoint List CRUD Operations Using PowerShell, Microsoft Graph API & GitHub Actions

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

Featured Post

Automate SharePoint List CRUD Operations Using PowerShell, Microsoft Graph API & GitHub Actions

Automate SharePoint List CRUD Operations Using PowerShell, Microsoft Graph API & GitHub Actions App-only Auth with Certificate   |  ...

Popular posts