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:
- Register Entra app + add API permissions
- Have an admin grant consent and site-level permission
3) Entra ID App Registration setup (Sites.Selected)
Step 3.1 — Register the app
- Microsoft Entra ID → App registrations → New registration
- Name:
Contoso.SharePoint.Unattended.CSOM - Register
Step 3.2 — Add SharePoint Application Permission: Sites.Selected
- App → API permissions → Add a permission
- Choose SharePoint
- Choose Application permissions
- Add: Sites.Selected
- 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
- App → Certificates & secrets → Certificates
- 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
- Visual Studio → Create a new project
- Console App (C#)
- .NET 8
- 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 Directory → Copy 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.Selectedis 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-PnPAzureADAppSitePermissionto confirm the grant (PNP GitHub)
10) Production hardening (recommended)
- Do not keep the
.pfxnext 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:
Get-...,Set-...,Revoke-...(PNP GitHub)
Summary table — Sites.Selected workflow
| Phase | What you do | Tool |
|---|---|---|
| Tenant consent | Add SharePoint (Application) Sites.Selected + admin consent | Entra Portal (TECHCOMMUNITY.MICROSOFT.COM) |
| Site grant | Grant app access to a specific site (Read/Write/Manage/FullControl) | PnP PowerShell (PNP GitHub) |
| Verify | List the site grants | Get-PnPAzureADAppSitePermission (PNP GitHub) |
| Update | Change the grant level | Set-PnPAzureADAppSitePermission (PNP GitHub) |
| Revoke | Remove access | Revoke-PnPAzureADAppSitePermission (PNP GitHub) |
| App code | No code changes between FullControl.All and Sites.Selected | C# / CSOM |
Summary table — Minimal permissions to “create list item”
| Operation | Recommended site permission |
|---|---|
| Read list items only | Read (PNP GitHub) |
| Create/update list items | Write (PNP GitHub) |
| Manage list settings / permissions | Manage / FullControl (avoid unless required) (PNP GitHub) |
