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:
- Authenticate using Microsoft Identity (MSAL) with client credentials
- Use an X.509 certificate (PFX) as the credential (more secure than client secret)
- 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)
- Loads
appsettings.jsonintoAppConfig - Validates required settings (fail fast)
- Loads the certificate from
.pfx - Requests an app-only token for the SharePoint tenant host
- Creates a
ClientContextand attachesAuthorization: Bearer <token> - Runs a sanity check (
Web.Title) - 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 intoAppConfigValidateConfig()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 loadedExportable: 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
Bodyif 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)
| Step | Action | Code Area |
|---|---|---|
| 1 | Load appsettings.json | LoadConfig() |
| 2 | Validate required config | ValidateConfig() |
| 3 | Load .pfx certificate | LoadCertificateFromPfx() |
| 4 | Acquire app-only token for SharePoint | AcquireSharePointTokenWithCertificateAsync() |
| 5 | Inject Bearer token into CSOM calls | ExecutingWebRequest |
| 6 | Sanity check by loading Web.Title | ctx.Load + ExecuteQuery |
| 7 | Create list item, print ID | CreateListItem |
Technical mapping
| Topic | What the code does |
|---|---|
| App-only authentication | Uses MSAL AcquireTokenForClient() |
| Certificate credential | Uses X509Certificate2 from PFX |
| SharePoint token scope | https://tenant.sharepoint.com/.default |
| CSOM auth | Adds Authorization: Bearer <token> |
| Robust field update | Checks if Body exists before setting |
| Reliability | Retries transient errors with backoff |
| Long jobs | Token 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.
