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)

  1. App Registration → API permissions
  2. Add SharePointApplication permissions
  3. Add Sites.Selected
  4. Grant admin consent
  5. (Recommended) Remove Sites.FullControl.All after 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 Write for 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

  1. Run mmc
  2. Add snap-in: CertificatesComputer account → Local computer
  3. Import the .pfx into:
    • Certificates (Local Computer)PersonalCertificates

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 TasksManage 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.Selected but 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

StepActionWho usually does it
1Add SharePoint App permission Sites.Selected + admin consentTenant/Entra admin
2Grant app access to site with WriteSharePoint/Entra admin
3Import PFX into Cert Store (LocalMachine recommended)Server admin
4Give scheduled task account private key read accessServer admin
5Run C# app loading cert by thumbprintYou

Summary table — Technical

TopicKey point
Why not keep PFX by EXEPFX = private key; copying it = copying app identity
Best storageWindows Cert Store + thumbprint
Sites.Selected behaviorNo site grant = no access; grant controls Read/Write/etc.
Code changes from FullControl.AllNone — only authorization (site grant) changes

Edvaldo Guimrães Filho Avatar

Published by