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.Selected or Sites.FullControl.All)
  • You authenticate app-only to SharePoint using a certificate (Microsoft Learn)
  • Your code uses MSAL:

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

  1. Go to Microsoft Entra ID
  2. App registrationsNew registration
  3. Name: Contoso.SharePoint.Unattended.CSOM
  4. 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:


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

  1. Entra App → Certificates & secrets
  2. Tab Certificates
  3. Upload certificate
  4. 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

  1. Visual Studio → Create a new project
  2. Choose Console App (C#)
  3. Framework: .NET 8
  4. Name: SharePoint.Unattended.CsomDemo

Step 5.2 — Install NuGet packages

Right-click project → Manage NuGet Packages → install:

Step 5.3 — Add appsettings.json

Add a JSON file named appsettings.json and set:

  • Copy to Output DirectoryCopy 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 DirectoryCopy 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”

  • ListTitle must 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

StepAction
1Register Entra ID App
2Add SharePoint Application permissions + admin consent
3Create cert (PFX/CER) and upload CER to the app
4Create Console App in Visual Studio
5Install Microsoft.Identity.Client + Microsoft.SharePointOnline.CSOM (NuGet)
6Add appsettings.json and certificate settings
7Implement MSAL WithCertificate + AcquireTokenForClient (Microsoft Learn)
8Inject Authorization: Bearer into CSOM and create list item
9(Optional) For Sites.Selected, grant site permission (pnp.github.io)

Summary table — Technical essentials

TopicWhat matters
Why certificateSharePoint Entra App-Only guidance requires certificate for CSOM/REST (Microsoft Learn)
Token flowAcquireTokenForClient is the client-credentials flow (no user) (Microsoft Learn)
MSAL setupWithCertificate(X509Certificate2) configures the confidential client (Microsoft Learn)
CSOM packageUse Microsoft.SharePointOnline.CSOM (NuGet)
Least privilegeSites.Selected + per-site grants via PnP cmdlet (pnp.github.io)
Legacy vs modernACS app-only differs from Entra app-only; modernization guidance exists (Microsoft Learn)

Edvaldo Guimrães Filho Avatar

Published by