Building a True Unattended SharePoint Online CSOM App in C# (Certificate-Based Entra ID App-Only) — Visual Studio Step-by-Step
This article walks you through creating a fully unattended (no interactive login) C# console app that:
- Authenticates using Microsoft Entra ID App-Only
- Uses a certificate (not a client secret)
- Calls SharePoint Online CSOM (
Microsoft.SharePoint.Client) - Creates a new item in a SharePoint list
Why certificate? Microsoft’s SharePoint guidance for Entra ID App-Only explicitly states that when doing app-only you must use a certificate to request access to SharePoint CSOM/REST APIs. (Microsoft Learn)
1) Background: Entra App-Only vs “Client Secret” confusion
Entra ID App-Only (modern model)
- You register an app in Microsoft Entra ID
- You assign SharePoint Application permissions (e.g.,
Sites.SelectedorSites.FullControl.All) - You authenticate app-only to SharePoint using a certificate (Microsoft Learn)
- Your code uses MSAL:
ConfidentialClientApplicationBuilder.WithCertificate(...)(Microsoft Learn)AcquireTokenForClient(...)(Microsoft Learn)
SharePoint App-Only (ACS legacy model)
There is an older “SharePoint App-Only” model (ACS) that historically used client id/secret and SharePoint add-in registration. It still exists for some scenarios, but it’s a different model than Entra App-Only. (Microsoft Learn)
If you’re using Entra App Registration + SharePoint Application permissions, follow the certificate path. (Microsoft Learn)
2) Prerequisites
- Visual Studio 2022 (Community/Pro/Enterprise)
- .NET 8 (or .NET 6/7)
- A SharePoint Online site (example placeholder):
https://contoso.sharepoint.com/sites/DevSite - A target list (example placeholder):
Example List
3) Entra ID setup (UI steps)
Step 3.1 — Register the app
- Go to Microsoft Entra ID
- App registrations → New registration
- Name:
Contoso.SharePoint.Unattended.CSOM - Register
Step 3.2 — Add SharePoint Application permissions
You have two typical choices:
Option A: Tenant-wide power (simpler)
- Add SharePoint Application permission:
Sites.FullControl.All - Click Grant admin consent
Option B: Least privilege (recommended)
- Add SharePoint Application permission:
Sites.Selected - Click Grant admin consent
- Then grant the app access to a specific site (see below)
To grant site access with PnP PowerShell (Sites.Selected), Microsoft community guidance commonly uses:
Grant-PnPAzureADAppSitePermission(pnp.github.io)
4) Create and upload the certificate
Step 4.1 — Create a self-signed cert (PowerShell)
Run PowerShell:
$cert = New-SelfSignedCertificate `
-Subject "CN=Contoso.SharePoint.Unattended" `
-CertStoreLocation "Cert:\CurrentUser\My" `
-KeySpec Signature `
-KeyExportPolicy Exportable `
-KeyLength 2048 `
-HashAlgorithm SHA256 `
-NotAfter (Get-Date).AddYears(2)
$pwd = ConvertTo-SecureString -String "P@ssw0rd-ChangeMe!" -Force -AsPlainText
Export-PfxCertificate -Cert $cert -FilePath ".\sp-unattended.pfx" -Password $pwd
Export-Certificate -Cert $cert -FilePath ".\sp-unattended.cer"
$cert.Thumbprint
Step 4.2 — Upload the certificate to your Entra App
- Entra App → Certificates & secrets
- Tab Certificates
- Upload certificate
- Upload
sp-unattended.cer
The SharePoint guidance highlights that possession of the certificate/private key effectively gives whoever holds it the app’s permissions—treat it like a credential. (Microsoft Learn)
5) Visual Studio project (UI steps)
Step 5.1 — Create the project
- Visual Studio → Create a new project
- Choose Console App (C#)
- Framework: .NET 8
- Name:
SharePoint.Unattended.CsomDemo
Step 5.2 — Install NuGet packages
Right-click project → Manage NuGet Packages → install:
Microsoft.Identity.Client(MSAL) (Microsoft Learn)Microsoft.SharePointOnline.CSOM(NuGet)
Step 5.3 — Add appsettings.json
Add a JSON file named appsettings.json and set:
- Copy to Output Directory →
Copy if newer
Example appsettings.json (placeholders):
{
"TenantId": "YOUR_TENANT_ID_GUID",
"ClientId": "YOUR_APP_CLIENT_ID_GUID",
"SharePointSiteUrl": "https://contoso.sharepoint.com/sites/DevSite",
"PfxPath": "sp-unattended.pfx",
"PfxPassword": "P@ssw0rd-ChangeMe!",
"ListTitle": "Example List",
"ItemTitle": "Created by unattended CSOM app",
"ItemBody": "Hello from certificate-based Entra app-only."
}
Tip (Visual Studio workflow): Add sp-unattended.pfx to the project and set Copy to Output Directory → Copy if newer.
6) Full working code (MSAL certificate + CSOM + create list item)
Replace your Program.cs with the full code below.
using System;
using System.IO;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
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; } = "";
}
internal static class Program
{
public static async Task<int> Main()
{
try
{
var config = LoadConfig("appsettings.json");
ValidateConfig(config);
Console.WriteLine("=== CONFIG ===");
Console.WriteLine("SiteUrl : " + config.SharePointSiteUrl);
Console.WriteLine("ListTitle: " + config.ListTitle);
// 1) Load certificate
var cert = LoadCertificateFromPfx(config.PfxPath, config.PfxPassword);
// 2) Acquire app-only token for SharePoint
var accessToken = await AcquireSharePointTokenWithCertificateAsync(
tenantId: config.TenantId,
clientId: config.ClientId,
certificate: cert,
siteUrl: config.SharePointSiteUrl);
// 3) Use CSOM with Bearer token
using (var ctx = new ClientContext(config.SharePointSiteUrl))
{
ctx.ExecutingWebRequest += (sender, e) =>
{
e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken;
};
// Sanity check: load web
var web = ctx.Web;
ctx.Load(web, w => w.Title, w => w.Url);
ctx.ExecuteQuery();
Console.WriteLine("SUCCESS (Web loaded)");
Console.WriteLine("Site: " + web.Title);
Console.WriteLine("Url : " + web.Url);
// Create list item
CreateListItem(ctx, config.ListTitle, config.ItemTitle, config.ItemBody);
}
return 0;
}
catch (Exception ex)
{
Console.WriteLine("ERROR");
Console.WriteLine(ex);
return 1;
}
}
private static void CreateListItem(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);
var itemCreateInfo = new ListItemCreationInformation();
var item = list.AddItem(itemCreateInfo);
// Most lists have 'Title'
item["Title"] = itemTitle ?? "";
// Optional field: 'Body' exists only on some list templates
if (!string.IsNullOrWhiteSpace(itemBody))
{
try { item["Body"] = itemBody; }
catch { /* ignore if field doesn't exist */ }
}
item.Update();
ctx.Load(item, i => i.Id);
ctx.ExecuteQuery();
Console.WriteLine("SUCCESS (Item created)");
Console.WriteLine("New Item ID: " + item.Id);
}
private static AppConfig LoadConfig(string fileName)
{
if (!System.IO.File.Exists(fileName))
throw new FileNotFoundException("Config file not found: " + fileName);
var json = System.IO.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.");
}
private static X509Certificate2 LoadCertificateFromPfx(string pfxPath, string password)
{
if (!System.IO.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> AcquireSharePointTokenWithCertificateAsync(
string tenantId,
string clientId,
X509Certificate2 certificate,
string siteUrl)
{
// SharePoint resource scope must be https://{tenant}.sharepoint.com/.default
var spHost = new Uri(siteUrl).GetLeftPart(UriPartial.Authority);
var scopes = new[] { spHost + "/.default" };
var app = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithTenantId(tenantId)
.WithCertificate(certificate) // certificate-based confidential client :contentReference[oaicite:10]{index=10}
.Build();
var result = await app.AcquireTokenForClient(scopes).ExecuteAsync(); // client-credentials flow :contentReference[oaicite:11]{index=11}
if (string.IsNullOrWhiteSpace(result.AccessToken))
throw new Exception("Access token is empty.");
return result.AccessToken;
}
}
7) Run and verify
Press F5.
Expected output pattern:
- Token acquired
- Web loaded (site title)
- Item created + new Item ID
8) Sites.Selected: grant access to a specific site (PnP PowerShell)
If you used Sites.Selected, you must grant the app permission to your target site. A common approach is PnP PowerShell:
Grant-PnPAzureADAppSitePermission -AppId <GUID> -Permissions <Read|Write|Manage|FullControl> -Site <url>(pnp.github.io)
This is the key detail many people miss: Sites.Selected alone grants nothing until you assign site permissions. (pnp.github.io)
9) Hardening for real unattended execution (Scheduled Task / Service)
Store the certificate more securely
Instead of shipping a .pfx alongside the exe:
- Import the cert into Local Machine certificate store
- Load it by thumbprint
- Or store it in a secure vault and retrieve it at runtime
This reduces the risk of someone copying the PFX and gaining your app’s permissions—an explicit risk called out in SharePoint’s certificate-based app-only model. (Microsoft Learn)
10) Troubleshooting checklist
401 Unauthorized
Most common causes:
- Wrong token audience (Graph token instead of SharePoint token)
- Missing SharePoint app permissions / consent not granted
- Using secret with Entra app-only + CSOM/REST (certificate is the supported path in SharePoint guidance) (Microsoft Learn)
“List does not exist”
ListTitlemust match the SharePoint list display name exactly.
“The field ‘Title’ does not exist”
- Some lists/libraries don’t use
Title. Use the correct internal field name for that list type.
Summary table — Build steps
| Step | Action |
|---|---|
| 1 | Register Entra ID App |
| 2 | Add SharePoint Application permissions + admin consent |
| 3 | Create cert (PFX/CER) and upload CER to the app |
| 4 | Create Console App in Visual Studio |
| 5 | Install Microsoft.Identity.Client + Microsoft.SharePointOnline.CSOM (NuGet) |
| 6 | Add appsettings.json and certificate settings |
| 7 | Implement MSAL WithCertificate + AcquireTokenForClient (Microsoft Learn) |
| 8 | Inject Authorization: Bearer into CSOM and create list item |
| 9 | (Optional) For Sites.Selected, grant site permission (pnp.github.io) |
Summary table — Technical essentials
| Topic | What matters |
|---|---|
| Why certificate | SharePoint Entra App-Only guidance requires certificate for CSOM/REST (Microsoft Learn) |
| Token flow | AcquireTokenForClient is the client-credentials flow (no user) (Microsoft Learn) |
| MSAL setup | WithCertificate(X509Certificate2) configures the confidential client (Microsoft Learn) |
| CSOM package | Use Microsoft.SharePointOnline.CSOM (NuGet) |
| Least privilege | Sites.Selected + per-site grants via PnP cmdlet (pnp.github.io) |
| Legacy vs modern | ACS app-only differs from Entra app-only; modernization guidance exists (Microsoft Learn) |
