Microsoft Graph + SharePoint End-to-End (Production Guide with Sites.Selected)

This article shows a complete, production-grade path to use Microsoft Graph with SharePoint Online using least privilege via Sites.Selected: from app registration and consent, to granting access to a specific site, and finally performing real SharePoint operations (like creating list items) with app-only authentication.
Key idea: Sites.Selected grants nothing by itself—you must assign site permissions explicitly. (Microsoft Learn)


0) Why Sites.Selected exists (and why you should care)

Traditional Graph application permissions like Sites.Read.All / Sites.ReadWrite.All are powerful: once admin-consented, the app can access all sites in the tenant (within that permission). Sites.Selected was introduced to support resource-specific consent so you can grant an application access to only specific site collections. (Microsoft Learn)

Reality check: after consenting Sites.Selected, your app still has no access until you grant it permissions for a target site. (Microsoft Learn)


1) Architecture pattern (recommended)

In real environments, you almost always end up with two roles:

A) “Worker app” (least privilege)

  • Has Graph Application permission: Sites.Selected
  • Runs unattended (service/daemon) to do real work (list items, files, pages, etc.)

B) “Manager” (admin) mechanism to assign site access

You need something with admin capability to run the grant step, such as:

  • PnP PowerShell as a one-time admin action, or (PnP GitHub)
  • A separate Manager app with enough rights to call POST /sites/{site-id}/permissions (admin-only operation in practice) (Microsoft Learn)

You can still do everything in one console tool for demo purposes, but conceptually these responsibilities differ.


2) Step-by-step: Entra ID App Registration (Worker app)

Step 2.1 — Register the app

Create an app registration in Microsoft Entra ID for your worker.

Step 2.2 — Add Graph permission

Add Microsoft Graph → Application permissions → Sites.Selected and then Grant admin consent. (Microsoft Learn)


3) Step-by-step: Find the target Site ID (Graph)

To grant permissions or call SharePoint resources, you often need the site ID.

Use Get site by path:
GET https://graph.microsoft.com/v1.0/sites/{hostname}:/{server-relative-path} (Microsoft Learn)

Example (placeholders):

  • Hostname: contoso.sharepoint.com
  • Path: /sites/ExampleSite

4) Step-by-step: Grant site permissions to the Worker app

Option A — Grant with Microsoft Graph (REST)

Use Create permission on a site:
POST /sites/{site-id}/permissions (Microsoft Learn)

Example body (write access):

{
  "roles": ["write"],
  "grantedToIdentities": [
    {
      "application": {
        "id": "WORKER-APP-CLIENT-ID-GUID",
        "displayName": "Contoso.SharePoint.Worker"
      }
    }
  ]
}

This creates an application permission on that site (not a user permission). (Microsoft Learn)

Option B — Grant with PnP PowerShell (simplest admin experience)

PnP provides Grant-PnPAzureADAppSitePermission, designed exactly for Sites.Selected. (PnP GitHub)

# Requires an admin-authenticated PnP connection to the target site
Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/ExampleSite" -Interactive

Grant-PnPAzureADAppSitePermission `
  -AppId "WORKER-APP-CLIENT-ID-GUID" `
  -DisplayName "Contoso.SharePoint.Worker" `
  -Permissions Write


5) Step-by-step: Perform SharePoint operations with Graph (Worker app)

Once the Worker app has:

  1. Sites.Selected consented, and
  2. explicit site permission assigned (read/write),

…it can call SharePoint Graph endpoints for that site. (Microsoft Learn)

5.1 Create a SharePoint list item (Graph)

Use:
POST /sites/{site-id}/lists/{list-id}/items with a fields object. (Microsoft Learn)


6) Full working example (C#): grant site permission + create a list item

This sample demonstrates the end-to-end flow:

  • Manager credentials (admin-capable) grant the permission on the site
  • Worker credentials (Sites.Selected) create the list item

In real production, the “grant” step is typically done once (PowerShell/automation), while the Worker runs daily/hourly.

appsettings.json

{
  "TenantId": "00000000-0000-0000-0000-000000000000",

  "ManagerApp": {
    "ClientId": "11111111-1111-1111-1111-111111111111",
    "ClientSecret": "MANAGER-APP-SECRET"
  },

  "WorkerApp": {
    "ClientId": "22222222-2222-2222-2222-222222222222",
    "ClientSecret": "WORKER-APP-SECRET",
    "DisplayName": "Contoso.SharePoint.Worker"
  },

  "Target": {
    "Hostname": "contoso.sharepoint.com",
    "SiteServerRelativePath": "/sites/ExampleSite",
    "ListDisplayName": "Example List",
    "NewItemTitle": "Created by Sites.Selected worker"
  }
}

Program.cs (full code)

using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Identity.Client;

internal sealed class RootConfig
{
    public string TenantId { get; set; } = "";

    public AppSecretConfig ManagerApp { get; set; } = new();
    public WorkerConfig WorkerApp { get; set; } = new();
    public TargetConfig Target { get; set; } = new();
}

internal sealed class AppSecretConfig
{
    public string ClientId { get; set; } = "";
    public string ClientSecret { get; set; } = "";
}

internal sealed class WorkerConfig : AppSecretConfig
{
    public string DisplayName { get; set; } = "Contoso.SharePoint.Worker";
}

internal sealed class TargetConfig
{
    public string Hostname { get; set; } = "contoso.sharepoint.com";
    public string SiteServerRelativePath { get; set; } = "/sites/ExampleSite";
    public string ListDisplayName { get; set; } = "Example List";
    public string NewItemTitle { get; set; } = "Created by Sites.Selected worker";
}

internal static class Program
{
    private static readonly string[] GraphScopes = new[] { "https://graph.microsoft.com/.default" };
    private const string GraphBase = "https://graph.microsoft.com/v1.0";

    public static async Task<int> Main()
    {
        try
        {
            var cfg = LoadConfig("appsettings.json");
            Validate(cfg);

            // 1) Resolve target siteId
            var managerToken = await AcquireAppOnlyTokenAsync(cfg.TenantId, cfg.ManagerApp.ClientId, cfg.ManagerApp.ClientSecret);
            var site = await GetSiteByPathAsync(managerToken, cfg.Target.Hostname, cfg.Target.SiteServerRelativePath);
            Console.WriteLine($"Site resolved: {site.Id} ({site.WebUrl})");

            // 2) Grant site permission to the Worker app (roles: write)
            await GrantWorkerAppSitePermissionAsync(
                managerToken,
                site.Id,
                cfg.WorkerApp.ClientId,
                cfg.WorkerApp.DisplayName,
                role: "write"
            );
            Console.WriteLine("Granted worker app site permission: write");

            // 3) Worker app uses Sites.Selected to operate on this site
            var workerToken = await AcquireAppOnlyTokenAsync(cfg.TenantId, cfg.WorkerApp.ClientId, cfg.WorkerApp.ClientSecret);

            // 4) Find listId by display name
            var listId = await FindListIdByDisplayNameAsync(workerToken, site.Id, cfg.Target.ListDisplayName);
            Console.WriteLine($"List resolved: {cfg.Target.ListDisplayName} -> {listId}");

            // 5) Create list item
            var newItemId = await CreateListItemAsync(workerToken, site.Id, listId, cfg.Target.NewItemTitle);
            Console.WriteLine($"List item created. Item id: {newItemId}");

            return 0;
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine("FAILED:");
            Console.Error.WriteLine(ex);
            return 1;
        }
    }

    // -------------------- Config --------------------

    private static RootConfig LoadConfig(string path)
    {
        var json = File.ReadAllText(path);
        return JsonSerializer.Deserialize<RootConfig>(json, new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        }) ?? throw new InvalidOperationException("Invalid appsettings.json");
    }

    private static void Validate(RootConfig cfg)
    {
        if (string.IsNullOrWhiteSpace(cfg.TenantId)) throw new ArgumentException("TenantId is required.");

        if (string.IsNullOrWhiteSpace(cfg.ManagerApp.ClientId)) throw new ArgumentException("ManagerApp.ClientId is required.");
        if (string.IsNullOrWhiteSpace(cfg.ManagerApp.ClientSecret)) throw new ArgumentException("ManagerApp.ClientSecret is required.");

        if (string.IsNullOrWhiteSpace(cfg.WorkerApp.ClientId)) throw new ArgumentException("WorkerApp.ClientId is required.");
        if (string.IsNullOrWhiteSpace(cfg.WorkerApp.ClientSecret)) throw new ArgumentException("WorkerApp.ClientSecret is required.");

        if (string.IsNullOrWhiteSpace(cfg.Target.Hostname)) throw new ArgumentException("Target.Hostname is required.");
        if (string.IsNullOrWhiteSpace(cfg.Target.SiteServerRelativePath)) throw new ArgumentException("Target.SiteServerRelativePath is required.");
        if (!cfg.Target.SiteServerRelativePath.StartsWith("/")) throw new ArgumentException("Target.SiteServerRelativePath must start with '/'.");
        if (string.IsNullOrWhiteSpace(cfg.Target.ListDisplayName)) throw new ArgumentException("Target.ListDisplayName is required.");
    }

    // -------------------- Auth --------------------

    private static async Task<string> AcquireAppOnlyTokenAsync(string tenantId, string clientId, string clientSecret)
    {
        var app = ConfidentialClientApplicationBuilder
            .Create(clientId)
            .WithClientSecret(clientSecret)
            .WithAuthority($"https://login.microsoftonline.com/{tenantId}")
            .Build();

        var result = await app.AcquireTokenForClient(GraphScopes).ExecuteAsync();
        return result.AccessToken;
    }

    // -------------------- Graph helpers --------------------

    private static HttpClient CreateHttp(string token)
    {
        var http = new HttpClient();
        http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
        http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        return http;
    }

    private static async Task<string> SendWithRetryAsync(HttpClient http, HttpMethod method, string url, string? jsonBody = null)
    {
        const int maxAttempts = 6;
        var attempt = 0;

        while (true)
        {
            attempt++;

            using var req = new HttpRequestMessage(method, url);
            if (jsonBody != null)
            {
                req.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
            }

            using var resp = await http.SendAsync(req);
            var body = await resp.Content.ReadAsStringAsync();

            if ((int)resp.StatusCode == 429)
            {
                if (attempt >= maxAttempts)
                    throw new HttpRequestException("Too many throttled attempts (429).");

                var delaySeconds = 30;
                if (resp.Headers.RetryAfter?.Delta != null)
                    delaySeconds = (int)Math.Ceiling(resp.Headers.RetryAfter.Delta.Value.TotalSeconds);

                await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
                continue;
            }

            if (!resp.IsSuccessStatusCode)
            {
                throw new HttpRequestException(
                    $"Graph call failed: {(int)resp.StatusCode} {resp.ReasonPhrase}\nURL: {url}\nBODY:\n{body}"
                );
            }

            return body;
        }
    }

    // -------------------- Step: Resolve site --------------------

    private sealed record SiteInfo(string Id, string WebUrl);

    private static async Task<SiteInfo> GetSiteByPathAsync(string token, string hostname, string serverRelativePath)
    {
        // GET /sites/{hostname}:/{server-relative-path}
        // Docs: Get SharePoint site by path
        var url = $"{GraphBase}/sites/{hostname}:{serverRelativePath}";
        using var http = CreateHttp(token);

        var json = await SendWithRetryAsync(http, HttpMethod.Get, url);
        using var doc = JsonDocument.Parse(json);

        var id = doc.RootElement.GetProperty("id").GetString() ?? "";
        var webUrl = doc.RootElement.TryGetProperty("webUrl", out var w) ? (w.GetString() ?? "") : "";

        if (string.IsNullOrWhiteSpace(id))
            throw new InvalidOperationException("Could not resolve site id.");

        return new SiteInfo(id, webUrl);
    }

    // -------------------- Step: Grant Sites.Selected access --------------------

    private static async Task GrantWorkerAppSitePermissionAsync(
        string managerToken,
        string siteId,
        string workerAppId,
        string workerDisplayName,
        string role)
    {
        // POST /sites/{site-id}/permissions
        // Creates an application permission on a site
        var url = $"{GraphBase}/sites/{siteId}/permissions";

        var payload = new
        {
            roles = new[] { role },
            grantedToIdentities = new[]
            {
                new
                {
                    application = new
                    {
                        id = workerAppId,
                        displayName = workerDisplayName
                    }
                }
            }
        };

        using var http = CreateHttp(managerToken);
        var jsonBody = JsonSerializer.Serialize(payload);

        await SendWithRetryAsync(http, HttpMethod.Post, url, jsonBody);
    }

    // -------------------- Step: Find list by display name --------------------

    private static async Task<string> FindListIdByDisplayNameAsync(string token, string siteId, string listDisplayName)
    {
        // Enumerate lists and find by displayName
        // Pattern docs: Working with SharePoint sites in Microsoft Graph
        var url = $"{GraphBase}/sites/{siteId}/lists?$select=id,displayName&$top=200";

        using var http = CreateHttp(token);
        var json = await SendWithRetryAsync(http, HttpMethod.Get, url);
        using var doc = JsonDocument.Parse(json);

        var values = doc.RootElement.GetProperty("value").EnumerateArray();
        foreach (var item in values)
        {
            var dn = item.GetProperty("displayName").GetString() ?? "";
            if (string.Equals(dn, listDisplayName, StringComparison.OrdinalIgnoreCase))
            {
                var id = item.GetProperty("id").GetString() ?? "";
                if (!string.IsNullOrWhiteSpace(id)) return id;
            }
        }

        throw new InvalidOperationException($"List not found: '{listDisplayName}'.");
    }

    // -------------------- Step: Create list item --------------------

    private static async Task<string> CreateListItemAsync(string token, string siteId, string listId, string title)
    {
        // POST /sites/{site-id}/lists/{list-id}/items
        // Body: { "fields": { "Title": "..." } }
        var url = $"{GraphBase}/sites/{siteId}/lists/{listId}/items";

        var payload = new
        {
            fields = new
            {
                Title = title
            }
        };

        using var http = CreateHttp(token);
        var jsonBody = JsonSerializer.Serialize(payload);

        var json = await SendWithRetryAsync(http, HttpMethod.Post, url, jsonBody);
        using var doc = JsonDocument.Parse(json);

        // Graph returns listItem with "id" (string)
        var itemId = doc.RootElement.GetProperty("id").GetString() ?? "";
        return itemId;
    }
}

Why these endpoints are “the correct primitives”

  • Get site by path: GET /sites/{hostname}:/{relative-path} (Microsoft Learn)
  • Create site permission: POST /sites/{site-id}/permissions creates an application permission (Microsoft Learn)
  • Create list item: POST /sites/{site-id}/lists/{list-id}/items with fields (Microsoft Learn)

7) Troubleshooting (the stuff that usually breaks)

Problem: 403 Forbidden after consenting Sites.Selected

That’s expected if you didn’t grant site permission yet. Sites.Selected alone doesn’t allow access. (Microsoft Learn)

Problem: Grant call fails / permission not applied

  • Ensure you’re using a principal with sufficient rights to grant application permissions on sites (commonly an admin workflow). (Microsoft Learn)
  • For simpler governance, use PnP’s cmdlets. (PnP GitHub)

Problem: You can’t find the site

Use the “get by path” endpoint and validate the server-relative path format. (Microsoft Learn)


8) Production hardening checklist

Least privilege

  • Prefer Sites.Selected + explicit site grants over tenant-wide permissions. (Microsoft Learn)

Reliability

  • Implement retry for 429 throttling and honor Retry-After (already in the code). (General Graph best practice; throttling is normal at scale.)

Maintainability

  • Separate “grant” workflow from the worker runtime.
  • Log: siteId, listId, Graph request IDs, and failures with payload snippets.

References (official/high-signal)


Summary tables

A) End-to-end steps

StepActionOutcome
1Register Worker app in Entra IDApp identity created
2Add Graph Application permission Sites.Selected + admin consentWorker can potentially be authorized (but still has no site access) (Microsoft Learn)
3Resolve site ID with GET /sites/{hostname}:/{path}You have the siteId (Microsoft Learn)
4Grant site permission POST /sites/{siteId}/permissions (or PnP cmdlet)Worker gains read/write on that specific site (Microsoft Learn)
5Worker uses POST /sites/{siteId}/lists/{listId}/itemsList item created successfully (Microsoft Learn)

B) Technical takeaways

TopicRule
Sites.SelectedGrants zero access until you assign permissions on the target site (Microsoft Learn)
GrantingPOST /sites/{site-id}/permissions creates application site permission (Microsoft Learn)
Site resolutionUse GET /sites/{hostname}:/{server-relative-path} (Microsoft Learn)
List item creationUse POST /sites/{siteId}/lists/{listId}/items with fields (Microsoft Learn)
OpsBuild retries for 429 and log failures (production requirement)
Edvaldo Guimrães Filho Avatar

Published by