Blog Article: Unattended SharePoint CSOM Automation with MSAL + Certificate (PFX)

This article explains how the following C# console application works—end-to-end—while keeping all customer information anonymized. The pattern is ideal for Windows Task Scheduler, background jobs, and migration utilities where interactive login is impossible.

Placeholders used throughout:

  • Site: https://contoso.sharepoint.com/sites/ExampleSite
  • List: ExampleList

Why this approach exists

SharePoint CSOM was originally built around interactive or legacy auth patterns. In modern automation, the most reliable way to run unattended is:

  1. Authenticate using Microsoft Identity (MSAL) with client credentials
  2. Use an X.509 certificate (PFX) as the credential (more secure than client secret)
  3. Inject the resulting OAuth token into CSOM requests via ExecutingWebRequest

This gives you:

  • App-only access (no user context)
  • Strong automation story for scheduled execution
  • Centralized security control (certificate rotation, app permissions)

What the program does (flow)

  1. Loads appsettings.json into AppConfig
  2. Validates required settings (fail fast)
  3. Loads the certificate from .pfx
  4. Requests an app-only token for the SharePoint tenant host
  5. Creates a ClientContext and attaches Authorization: Bearer <token>
  6. Runs a sanity check (Web.Title)
  7. Creates a list item in the configured list and prints the new item ID

Prerequisites (outside the code)

1) Entra ID App Registration

You must have an app registration with:

  • TenantId
  • ClientId
  • A certificate registered under Certificates & secrets

2) SharePoint permissions

Your app must be granted permissions that allow:

  • Reading the site (for the sanity check)
  • Writing to the target list (for item creation)

If permissions are wrong, your failure will typically be 401/403 during ExecuteQuery().


The configuration file (anonymized example)

appsettings.json:

{
"TenantId": "00000000-0000-0000-0000-000000000000",
"ClientId": "11111111-1111-1111-1111-111111111111",
"SharePointSiteUrl": "https://contoso.sharepoint.com/sites/ExampleSite",
"PfxPath": "C:\\certs\\sp-unattended.pfx",
"PfxPassword": "YOUR_PFX_PASSWORD",
"ListTitle": "ExampleList",
"ItemTitle": "Created by unattended CSOM app",
"ItemBody": "Hello from scheduled automation"
}

Code explanation (section by section)

1) AppConfig: strongly-typed settings

The AppConfig class defines everything the app needs at runtime. This makes it easy to run the same binary against multiple environments by swapping the JSON file.

2) LoadConfig() + ValidateConfig(): fail fast

  • LoadConfig() reads JSON and deserializes into AppConfig
  • ValidateConfig() throws clear errors if anything is missing
    This avoids confusing failures later (like “token empty” or “list not found” when the real issue was config).

3) LoadCertificateFromPfx(): certificate-based auth

The code loads a .pfx file into an X509Certificate2.

Key flags used:

  • MachineKeySet: helps scheduled tasks/services where user profile isn’t loaded
  • Exportable: convenient, but consider removing in hardened environments

4) AcquireSharePointTokenWithCertificateAsync(): token for SharePoint, not Graph

This part is the core:

  • It extracts the host:
    • https://contoso.sharepoint.com
  • Then requests scope:
    • https://contoso.sharepoint.com/.default

That .default means: “Issue a token using the application permissions configured for this resource.”

5) CSOM bearer injection: ExecutingWebRequest

CSOM doesn’t “know” MSAL. So you attach the token yourself:

ctx.ExecutingWebRequest += (sender, e) =>
{
e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken;
};

Every CSOM call made by ExecuteQuery() will carry your token.

6) CreateListItem(): safe write pattern

The code:

  • Gets the list by title
  • Adds an item
  • Sets Title
  • Tries to set Body if present (some lists don’t have it)
  • Executes and prints new item ID

Production-hardened version (recommended)

Below is a drop-in improved version that adds:

  • Retry with exponential backoff on transient failures (throttling / temporary network issues)
  • “Body field exists?” check instead of try/catch
  • Token caching + automatic refresh when close to expiry
  • Cleaner console logs for scheduled execution

Full code (single file)

using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client;
using Microsoft.SharePoint.Client;
internal class AppConfig
{
public string TenantId { get; set; } = "";
public string ClientId { get; set; } = "";
public string SharePointSiteUrl { get; set; } = "";
public string PfxPath { get; set; } = "";
public string PfxPassword { get; set; } = "";
public string ListTitle { get; set; } = "";
public string ItemTitle { get; set; } = "Created by unattended CSOM app";
public string ItemBody { get; set; } = "";
// Optional hardening knobs
public int MaxRetries { get; set; } = 5;
public int RetryBaseDelayMs { get; set; } = 800;
}
internal static class Program
{
private static readonly SemaphoreSlim TokenLock = new SemaphoreSlim(1, 1);
private static string _cachedToken = "";
private static DateTimeOffset _cachedTokenExpiresOn = DateTimeOffset.MinValue;
public static async Task<int> Main()
{
try
{
var config = LoadConfig("appsettings.json");
ValidateConfig(config);
Console.WriteLine("=== CONFIG (Anonymized) ===");
Console.WriteLine("SiteUrl : " + config.SharePointSiteUrl);
Console.WriteLine("ListTitle: " + config.ListTitle);
// 1) Load certificate (PFX)
var cert = LoadCertificateFromPfx(config.PfxPath, config.PfxPassword);
// 2) Create CSOM context
using (var ctx = new ClientContext(config.SharePointSiteUrl))
{
// Attach token automatically (with refresh)
ctx.ExecutingWebRequest += async (sender, e) =>
{
var token = await GetValidSharePointTokenAsync(
tenantId: config.TenantId,
clientId: config.ClientId,
certificate: cert,
siteUrl: config.SharePointSiteUrl
);
e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + token;
};
// 3) Sanity check (Web)
await ExecuteQueryWithRetryAsync(ctx, config, async () =>
{
var web = ctx.Web;
ctx.Load(web, w => w.Title, w => w.Url);
await ctx.ExecuteQueryAsync();
Console.WriteLine("SUCCESS (Web loaded)");
Console.WriteLine("Site: " + web.Title);
});
// 4) Create item
await ExecuteQueryWithRetryAsync(ctx, config, async () =>
{
await CreateListItemAsync(ctx, config.ListTitle, config.ItemTitle, config.ItemBody);
});
}
return 0;
}
catch (Exception ex)
{
Console.WriteLine("ERROR");
Console.WriteLine(ex);
return 1;
}
}
private static async Task CreateListItemAsync(ClientContext ctx, string listTitle, string itemTitle, string itemBody)
{
if (string.IsNullOrWhiteSpace(listTitle))
throw new Exception("ListTitle is required in appsettings.json");
var list = ctx.Web.Lists.GetByTitle(listTitle);
// Load fields once to check if "Body" exists
ctx.Load(list, l => l.Fields.Include(f => f.InternalName));
await ctx.ExecuteQueryAsync();
var hasBody = list.Fields.Any(f => string.Equals(f.InternalName, "Body", StringComparison.OrdinalIgnoreCase));
var itemCreateInfo = new ListItemCreationInformation();
var item = list.AddItem(itemCreateInfo);
item["Title"] = itemTitle ?? "";
if (hasBody && !string.IsNullOrWhiteSpace(itemBody))
{
item["Body"] = itemBody;
}
item.Update();
ctx.Load(item, i => i.Id);
await ctx.ExecuteQueryAsync();
Console.WriteLine("SUCCESS (Item created)");
Console.WriteLine("New Item ID: " + item.Id);
}
private static AppConfig LoadConfig(string fileName)
{
if (!File.Exists(fileName))
throw new FileNotFoundException("Config file not found: " + fileName);
var json = File.ReadAllText(fileName);
var cfg = JsonSerializer.Deserialize<AppConfig>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (cfg == null)
throw new InvalidOperationException("Failed to parse config file.");
return cfg;
}
private static void ValidateConfig(AppConfig cfg)
{
if (string.IsNullOrWhiteSpace(cfg.TenantId)) throw new Exception("TenantId is required.");
if (string.IsNullOrWhiteSpace(cfg.ClientId)) throw new Exception("ClientId is required.");
if (string.IsNullOrWhiteSpace(cfg.SharePointSiteUrl)) throw new Exception("SharePointSiteUrl is required.");
if (string.IsNullOrWhiteSpace(cfg.PfxPath)) throw new Exception("PfxPath is required.");
if (string.IsNullOrWhiteSpace(cfg.PfxPassword)) throw new Exception("PfxPassword is required.");
if (string.IsNullOrWhiteSpace(cfg.ListTitle)) throw new Exception("ListTitle is required.");
if (cfg.MaxRetries < 0) cfg.MaxRetries = 0;
if (cfg.RetryBaseDelayMs < 0) cfg.RetryBaseDelayMs = 0;
}
private static X509Certificate2 LoadCertificateFromPfx(string pfxPath, string password)
{
if (!File.Exists(pfxPath))
throw new FileNotFoundException("PFX file not found: " + pfxPath);
return new X509Certificate2(
pfxPath,
password,
X509KeyStorageFlags.MachineKeySet |
X509KeyStorageFlags.Exportable
);
}
private static async Task<string> GetValidSharePointTokenAsync(
string tenantId,
string clientId,
X509Certificate2 certificate,
string siteUrl)
{
// Refresh token if it expires soon (5 minutes buffer)
var now = DateTimeOffset.UtcNow;
if (!string.IsNullOrWhiteSpace(_cachedToken) && _cachedTokenExpiresOn > now.AddMinutes(5))
return _cachedToken;
await TokenLock.WaitAsync();
try
{
// Double-check after acquiring lock
now = DateTimeOffset.UtcNow;
if (!string.IsNullOrWhiteSpace(_cachedToken) && _cachedTokenExpiresOn > now.AddMinutes(5))
return _cachedToken;
var (token, expiresOn) = await AcquireSharePointTokenWithCertificateAsync(
tenantId: tenantId,
clientId: clientId,
certificate: certificate,
siteUrl: siteUrl
);
_cachedToken = token;
_cachedTokenExpiresOn = expiresOn;
return _cachedToken;
}
finally
{
TokenLock.Release();
}
}
private static async Task<(string token, DateTimeOffset expiresOn)> AcquireSharePointTokenWithCertificateAsync(
string tenantId,
string clientId,
X509Certificate2 certificate,
string siteUrl)
{
var spHost = new Uri(siteUrl).GetLeftPart(UriPartial.Authority); // https://tenant.sharepoint.com
var scopes = new[] { spHost + "/.default" };
Console.WriteLine("=== TOKEN REQUEST ===");
Console.WriteLine("Scope: " + scopes[0]);
var app = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithTenantId(tenantId)
.WithCertificate(certificate)
.Build();
var result = await app.AcquireTokenForClient(scopes).ExecuteAsync();
if (string.IsNullOrWhiteSpace(result.AccessToken))
throw new Exception("Access token is empty.");
Console.WriteLine("Token acquired. ExpiresOn(UTC): " + result.ExpiresOn.UtcDateTime.ToString("O"));
return (result.AccessToken, result.ExpiresOn);
}
private static async Task ExecuteQueryWithRetryAsync(ClientContext ctx, AppConfig cfg, Func<Task> action)
{
int attempt = 0;
while (true)
{
try
{
await action();
return;
}
catch (ClientRequestException ex) when (IsTransient(ex))
{
attempt++;
if (attempt > cfg.MaxRetries) throw;
var delay = CalculateDelay(cfg.RetryBaseDelayMs, attempt);
Console.WriteLine($"WARN: Transient CSOM error. Attempt {attempt}/{cfg.MaxRetries}. Waiting {delay}ms...");
await Task.Delay(delay);
}
catch (ServerException ex) when (IsTransient(ex))
{
attempt++;
if (attempt > cfg.MaxRetries) throw;
var delay = CalculateDelay(cfg.RetryBaseDelayMs, attempt);
Console.WriteLine($"WARN: Transient SharePoint error. Attempt {attempt}/{cfg.MaxRetries}. Waiting {delay}ms...");
await Task.Delay(delay);
}
catch (WebException ex) when (IsTransient(ex))
{
attempt++;
if (attempt > cfg.MaxRetries) throw;
var delay = CalculateDelay(cfg.RetryBaseDelayMs, attempt);
Console.WriteLine($"WARN: Transient network error. Attempt {attempt}/{cfg.MaxRetries}. Waiting {delay}ms...");
await Task.Delay(delay);
}
}
}
private static bool IsTransient(Exception ex)
{
// Typical transient cases: throttling, temporary network issues, server busy.
// CSOM sometimes surfaces throttling as ServerException with "429" or "throttle" text.
var msg = (ex.Message ?? "").ToLowerInvariant();
if (msg.Contains("429")) return true;
if (msg.Contains("throttle")) return true;
if (msg.Contains("server busy")) return true;
if (msg.Contains("timeout")) return true;
if (msg.Contains("temporarily unavailable")) return true;
return false;
}
private static int CalculateDelay(int baseDelayMs, int attempt)
{
// Exponential backoff with a small cap to keep scheduled tasks predictable
// delay = base * 2^(attempt-1)
double delay = baseDelayMs * Math.Pow(2, attempt - 1);
if (delay > 15000) delay = 15000;
return (int)delay;
}
}

Troubleshooting (most common failures)

1) 401 Unauthorized

Usually means:

  • wrong scope (should be https://tenant.sharepoint.com/.default)
  • certificate not correctly registered in the app
  • token issued but SharePoint doesn’t accept it (permissions/misconfig)

2) 403 Forbidden / Access denied

Usually means:

  • the app does not have permission to the site or list
  • list permissions are unique and the app lacks rights
  • you can read the web but cannot write to the list

3) Scheduled task works manually, fails when scheduled

Usually means:

  • scheduled task account cannot read C:\certs\sp-unattended.pfx
  • machine key store issues (certificate private key not loadable)
  • working directory differs; JSON file path not found

Fix pattern: use absolute paths, ensure file ACLs for the task identity.


Final summary tables

Steps (runtime execution)

StepActionCode Area
1Load appsettings.jsonLoadConfig()
2Validate required configValidateConfig()
3Load .pfx certificateLoadCertificateFromPfx()
4Acquire app-only token for SharePointAcquireSharePointTokenWithCertificateAsync()
5Inject Bearer token into CSOM callsExecutingWebRequest
6Sanity check by loading Web.Titlectx.Load + ExecuteQuery
7Create list item, print IDCreateListItem

Technical mapping

TopicWhat the code does
App-only authenticationUses MSAL AcquireTokenForClient()
Certificate credentialUses X509Certificate2 from PFX
SharePoint token scopehttps://tenant.sharepoint.com/.default
CSOM authAdds Authorization: Bearer <token>
Robust field updateChecks if Body exists before setting
ReliabilityRetries transient errors with backoff
Long jobsToken caching + refresh before expiry

If you want the next step, I can also format this into a ready-to-publish blog post with: title variants, SEO-friendly headings, diagram (ASCII architecture), and a “security hardening checklist” for enterprise reviews.

Edvaldo Guimrães Filho Avatar

Published by