Best practice for unattended apps:
Ensure only the scheduled task account can access the private key
Import the certificate into Windows Certificate Store (LocalMachine or CurrentUser)
Load by Thumbprint
Below is exactly that: certificate stored in Windows Certificate Store, loaded by thumbprint, and used to create a list item with Sites.Selected.
Why you should NOT keep the .pfx next to the EXE
A .pfx contains the private key. If someone copies that file (plus password), they can impersonate your Entra app and get the same SharePoint access you granted (including Sites.Selected grants).
Best practice for unattended apps:
- Import the certificate into Windows Certificate Store (LocalMachine or CurrentUser)
- Load by Thumbprint
- Ensure only the scheduled task account can access the private key
This way:
- No PFX file is distributed with the binary
- You reduce “copy & run elsewhere” risk
Step-by-step: Sites.Selected + Certificate in Windows Store
Step 1 — Entra ID permissions (one-time)
- App Registration → API permissions
- Add SharePoint → Application permissions
- Add Sites.Selected
- Grant admin consent
- (Recommended) Remove
Sites.FullControl.Allafter validating
Step 2 — Grant the app access to ONE site (Sites.Selected is empty until this)
Run (PnP PowerShell), with an admin account:
Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/DevSite" -Interactive
Grant-PnPAzureADAppSitePermission `
-AppId "<YOUR-CLIENT-ID-GUID>" `
-DisplayName "Contoso.SharePoint.Unattended.CSOM" `
-Site "https://contoso.sharepoint.com/sites/DevSite" `
-Permissions Write
- Use
Writefor creating list items - If you only read, use
Read
Step 3 — Import the certificate into Windows Certificate Store (NO PFX beside EXE)
Option A (Recommended for Scheduled Task): LocalMachine store
- Run
mmc - Add snap-in: Certificates → Computer account → Local computer
- Import the
.pfxinto:- Certificates (Local Computer) → Personal → Certificates
Option B: CurrentUser store
Works only if the app always runs as your user and that profile is loaded.
Step 4 — Get the certificate thumbprint
In MMC:
- open the cert → Details → Thumbprint
Copy it and remove spaces.
Step 5 — Give the Scheduled Task account access to the private key
If you use LocalMachine store:
- Right-click cert → All Tasks → Manage Private Keys…
- Add the Windows account that will run the scheduled task
- Grant Read
This is critical. Without private key access, MSAL can’t sign the assertion and token acquisition fails.
Sample C# App (illustrates Sites.Selected + Cert Store)
appsettings.json
Create appsettings.json and set Copy to Output Directory = Copy if newer:
{
"TenantId": "YOUR_TENANT_ID_GUID",
"ClientId": "YOUR_CLIENT_ID_GUID",
"SharePointSiteUrl": "https://contoso.sharepoint.com/sites/DevSite",
"ListTitle": "Example List",
"ItemTitle": "Created by Sites.Selected + CertStore",
"ItemBody": "No PFX next to EXE. Cert is in Windows store.",
"CertThumbprint": "YOUR_CERT_THUMBPRINT_NO_SPACES",
"CertStoreLocation": "LocalMachine"
}
Allowed values for CertStoreLocation:
LocalMachine(recommended for scheduled tasks)CurrentUser
Program.cs (full code, copy/paste)
using System;
using System.IO;
using System.Linq;
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 ListTitle { get; set; } = "";
public string ItemTitle { get; set; } = "Created by unattended CSOM app";
public string ItemBody { get; set; } = "";
// Certificate store settings
public string CertThumbprint { get; set; } = "";
public string CertStoreLocation { get; set; } = "LocalMachine"; // or "CurrentUser"
}
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);
Console.WriteLine("CertThumbprint : " + config.CertThumbprint);
Console.WriteLine("CertStoreLocation: " + config.CertStoreLocation);
// 1) Load certificate from Windows Certificate Store
var cert = LoadCertificateFromStore(config.CertThumbprint, config.CertStoreLocation);
// 2) Acquire app-only token for SharePoint using certificate
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: load site
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 (requires Sites.Selected grant with Write)
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.");
var list = ctx.Web.Lists.GetByTitle(listTitle);
var itemCreateInfo = new ListItemCreationInformation();
var item = list.AddItem(itemCreateInfo);
item["Title"] = itemTitle ?? "";
// Optional: only works if the list has a field named "Body"
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.ListTitle)) throw new Exception("ListTitle is required.");
if (string.IsNullOrWhiteSpace(cfg.CertThumbprint)) throw new Exception("CertThumbprint is required.");
}
private static X509Certificate2 LoadCertificateFromStore(string thumbprint, string storeLocationText)
{
if (string.IsNullOrWhiteSpace(thumbprint))
throw new Exception("Thumbprint is empty.");
thumbprint = thumbprint.Replace(" ", "").ToUpperInvariant();
StoreLocation storeLocation;
if (!Enum.TryParse(storeLocationText, ignoreCase: true, out storeLocation))
storeLocation = StoreLocation.LocalMachine;
using (var store = new X509Store(StoreName.My, storeLocation))
{
store.Open(OpenFlags.ReadOnly);
var matches = store.Certificates.Find(
X509FindType.FindByThumbprint,
thumbprint,
validOnly: false);
if (matches.Count == 0)
throw new Exception($"Certificate not found in {storeLocation}\\My. Thumbprint: {thumbprint}");
var cert = matches[0];
if (!cert.HasPrivateKey)
throw new Exception("Certificate was found but it does NOT have a private key. Import the PFX (not only CER).");
return cert;
}
}
private static async Task<string> 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.");
return result.AccessToken;
}
}
How this illustrates Sites.Selected
This app will behave like this:
- If the Entra app only has
Sites.Selectedbut you didn’t grant the site permission → you’ll usually get 403 Forbidden when trying to load the site or create items. - If the site grant exists but only Read → loading might work, but creating an item fails (403).
- If the site grant exists with Write → creating an item succeeds.
That’s the “selected sites” model in practice.
Summary table — Steps
| Step | Action | Who usually does it |
|---|---|---|
| 1 | Add SharePoint App permission Sites.Selected + admin consent | Tenant/Entra admin |
| 2 | Grant app access to site with Write | SharePoint/Entra admin |
| 3 | Import PFX into Cert Store (LocalMachine recommended) | Server admin |
| 4 | Give scheduled task account private key read access | Server admin |
| 5 | Run C# app loading cert by thumbprint | You |
Summary table — Technical
| Topic | Key point |
|---|---|
| Why not keep PFX by EXE | PFX = private key; copying it = copying app identity |
| Best storage | Windows Cert Store + thumbprint |
| Sites.Selected behavior | No site grant = no access; grant controls Read/Write/etc. |
| Code changes from FullControl.All | None — only authorization (site grant) changes |
