Building an Unattended SharePoint Online CSOM App with Sites.Selected (Certificate-Based Entra ID App-Only) — End-to-End Guide

This guide shows how to build a true unattended C# console app (no user login) that:

  • Authenticates with Microsoft Entra ID app-only
  • Uses a certificate for client credentials
  • Uses CSOM (Microsoft.SharePointOnline.CSOM)
  • Accesses only specific SharePoint sites via Sites.Selected
  • Creates a new item in a SharePoint list

The key concept: Sites.Selected grants zero access by default—you must explicitly grant the app access to the target site after admin consent. (Microsoft Learn)


1) Why Sites.Selected matters

The problem with tenant-wide permissions

Permissions like Sites.FullControl.All apply across the tenant. It’s easy, but it’s broad.

What Sites.Selected gives you

With Sites.Selected, you get least privilege:

  • The app can access only the sites you grant
  • The access level can be Read / Write / Manage / FullControl per site (PNP GitHub)

This is an implementation of Resource Specific Consent (RSC) for SharePoint Online, where permissions are scoped at the site level. (Microsoft Learn)


2) Prerequisites

  • Visual Studio 2022
  • .NET 8 (or .NET 6/7)
  • A SharePoint Online site (use placeholders in your documentation):
    • https://contoso.sharepoint.com/sites/DevSite
  • A list to write into:
    • Example List
  • Ability to:
    1. Register Entra app + add API permissions
    2. Have an admin grant consent and site-level permission

3) Entra ID App Registration setup (Sites.Selected)

Step 3.1 — Register the app

  1. Microsoft Entra ID → App registrationsNew registration
  2. Name: Contoso.SharePoint.Unattended.CSOM
  3. Register

Step 3.2 — Add SharePoint Application Permission: Sites.Selected

  1. App → API permissionsAdd a permission
  2. Choose SharePoint
  3. Choose Application permissions
  4. Add: Sites.Selected
  5. Click Grant admin consent

Important: there are “selected permissions” concepts in different APIs; for CSOM/SharePoint REST, you want the SharePoint Sites.Selected app permission. (TECHCOMMUNITY.MICROSOFT.COM)


4) Certificate requirement (for Entra app-only to SharePoint CSOM/REST)

When using the Entra ID app-only model against SharePoint CSOM/REST, SharePoint guidance explicitly calls out using a certificate for the app-only access model. (Microsoft Learn)

Step 4.1 — Create a certificate (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 public cert to the app

  1. App → Certificates & secretsCertificates
  2. Upload sp-unattended.cer

Security note: anyone holding the private key can impersonate the app’s permissions, so treat the certificate like a credential. (Microsoft Learn)


5) The critical step: grant the app access to a specific site

Even after admin consent, Sites.Selected alone gives no access until you grant site permissions. (Microsoft Learn)

Option A (most common): PnP PowerShell site grant

PnP provides purpose-built cmdlets for this Sites.Selected workflow. (PNP GitHub)

Step 5.1 — Install PnP.PowerShell

Install-Module PnP.PowerShell -Scope CurrentUser

Step 5.2 — Connect to the target site (interactive)

Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/DevSite" -Interactive

Step 5.3 — Grant site permission to your Entra app

For creating list items, Write is typically sufficient:

Grant-PnPAzureADAppSitePermission `
  -AppId "<YOUR-APP-CLIENT-ID-GUID>" `
  -DisplayName "Contoso.SharePoint.Unattended.CSOM" `
  -Site "https://contoso.sharepoint.com/sites/DevSite" `
  -Permissions Write

Grant-PnPAzureADAppSitePermission is explicitly intended to be used with SharePoint application permission Sites.Selected, and supports Read|Write|Manage|FullControl. (PNP GitHub)


6) Verify, update, and revoke site-level grants (governance)

Verify permissions

Get-PnPAzureADAppSitePermission -Site "https://contoso.sharepoint.com/sites/DevSite"

This cmdlet returns app permissions for a given site. (PNP GitHub)

Update permissions (e.g., Write → FullControl)

Set-PnPAzureADAppSitePermission `
  -PermissionId "<PERMISSION-ID>" `
  -Site "https://contoso.sharepoint.com/sites/DevSite" `
  -Permissions FullControl

This cmdlet updates a permission grant and is designed for Sites.Selected. (PNP GitHub)

Revoke permissions

Revoke-PnPAzureADAppSitePermission `
  -PermissionId "<PERMISSION-ID>" `
  -Site "https://contoso.sharepoint.com/sites/DevSite"

This cmdlet revokes the permission. (PNP GitHub)


7) Visual Studio project setup

Step 7.1 — Create the project

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

Step 7.2 — Install NuGet packages

  • Microsoft.Identity.Client (MSAL)
  • Microsoft.SharePointOnline.CSOM

MSAL client credential flow uses AcquireTokenForClient. (Microsoft Learn)

Step 7.3 — Add appsettings.json

Set Copy to Output DirectoryCopy if newer.

Example (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"
}


8) Full working code: Certificate + CSOM + Create List Item (Sites.Selected compatible)

This code does not change when you move from tenant-wide permissions to Sites.Selected.
The difference is purely in the site grant you added via PnP PowerShell.

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";
}

internal static class Program
{
    public static async Task<int> Main()
    {
        try
        {
            var config = LoadConfig("appsettings.json");
            ValidateConfig(config);

            // 1) Load certificate
            var cert = LoadCertificateFromPfx(config.PfxPath, config.PfxPassword);

            // 2) Acquire app-only token for SharePoint (client credentials flow)
            var accessToken = await AcquireSharePointTokenWithCertificateAsync(
                tenantId: config.TenantId,
                clientId: config.ClientId,
                certificate: cert,
                siteUrl: config.SharePointSiteUrl);

            // 3) CSOM with Bearer token
            using (var ctx = new ClientContext(config.SharePointSiteUrl))
            {
                ctx.ExecutingWebRequest += (sender, e) =>
                {
                    e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken;
                };

                // sanity check
                var web = ctx.Web;
                ctx.Load(web, w => w.Title, w => w.Url);
                ctx.ExecuteQuery();

                Console.WriteLine("SUCCESS");
                Console.WriteLine("Site: " + web.Title);

                // Create list item
                CreateListItem(ctx, config.ListTitle, config.ItemTitle);
            }

            return 0;
        }
        catch (Exception ex)
        {
            Console.WriteLine("ERROR");
            Console.WriteLine(ex);
            return 1;
        }
    }

    private static void CreateListItem(ClientContext ctx, string listTitle, string itemTitle)
    {
        if (string.IsNullOrWhiteSpace(listTitle))
            throw new Exception("ListTitle is required.");

        var list = ctx.Web.Lists.GetByTitle(listTitle);

        var itemCreateInfo = new ListItemCreationInformation();
        var item = list.AddItem(itemCreateInfo);

        item["Title"] = itemTitle ?? "";
        item.Update();

        ctx.Load(item, i => i.Id);
        ctx.ExecuteQuery();

        Console.WriteLine("Item created. 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)
    {
        var spHost = new Uri(siteUrl).GetLeftPart(UriPartial.Authority);
        var scopes = new[] { spHost + "/.default" };

        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.");

        return result.AccessToken;
    }
}


9) Troubleshooting: what failures mean with Sites.Selected

401 Unauthorized

Usually one of:

  • wrong resource scope (not using https://{tenant}.sharepoint.com/.default)
  • cert not uploaded / wrong cert used
  • consent not granted

MSAL client credential flow is still correct (AcquireTokenForClient). (Microsoft Learn)

403 Forbidden (very common with Sites.Selected)

This is the typical symptom of:

  • Sites.Selected is present, but no site grant exists for the target site
  • or you granted Read but your code performs Write (creating item requires Write)

Use:

  • Get-PnPAzureADAppSitePermission to confirm the grant (PNP GitHub)

10) Production hardening (recommended)

  • Do not keep the .pfx next to the executable long-term.
  • Prefer loading the cert from the Windows Certificate Store (thumbprint) or a secure vault.
  • Regularly audit and revoke unused site grants:

Summary table — Sites.Selected workflow

PhaseWhat you doTool
Tenant consentAdd SharePoint (Application) Sites.Selected + admin consentEntra Portal (TECHCOMMUNITY.MICROSOFT.COM)
Site grantGrant app access to a specific site (Read/Write/Manage/FullControl)PnP PowerShell (PNP GitHub)
VerifyList the site grantsGet-PnPAzureADAppSitePermission (PNP GitHub)
UpdateChange the grant levelSet-PnPAzureADAppSitePermission (PNP GitHub)
RevokeRemove accessRevoke-PnPAzureADAppSitePermission (PNP GitHub)
App codeNo code changes between FullControl.All and Sites.SelectedC# / CSOM

Summary table — Minimal permissions to “create list item”

OperationRecommended site permission
Read list items onlyRead (PNP GitHub)
Create/update list itemsWrite (PNP GitHub)
Manage list settings / permissionsManage / FullControl (avoid unless required) (PNP GitHub)

Edvaldo Guimrães Filho Avatar

Published by