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:
Sites.Selectedconsented, and- 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}/permissionscreates an application permission (Microsoft Learn) - Create list item:
POST /sites/{site-id}/lists/{list-id}/itemswithfields(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)
- Create site permission (
POST /sites/{site-id}/permissions) (Microsoft Learn) - Selected permissions overview (core concept: “consent + assignment + token”) (Microsoft Learn)
- SharePoint resource patterns in Graph (Microsoft Learn)
- Get site by path (Microsoft Learn)
- Create list item (Microsoft Learn)
- Resource-specific consent (SharePoint guidance + PnP cmdlets mention) (Microsoft Learn)
- PnP PowerShell:
Grant-PnPAzureADAppSitePermission(PnP GitHub)
Summary tables
A) End-to-end steps
| Step | Action | Outcome |
|---|---|---|
| 1 | Register Worker app in Entra ID | App identity created |
| 2 | Add Graph Application permission Sites.Selected + admin consent | Worker can potentially be authorized (but still has no site access) (Microsoft Learn) |
| 3 | Resolve site ID with GET /sites/{hostname}:/{path} | You have the siteId (Microsoft Learn) |
| 4 | Grant site permission POST /sites/{siteId}/permissions (or PnP cmdlet) | Worker gains read/write on that specific site (Microsoft Learn) |
| 5 | Worker uses POST /sites/{siteId}/lists/{listId}/items | List item created successfully (Microsoft Learn) |
B) Technical takeaways
| Topic | Rule |
|---|---|
Sites.Selected | Grants zero access until you assign permissions on the target site (Microsoft Learn) |
| Granting | POST /sites/{site-id}/permissions creates application site permission (Microsoft Learn) |
| Site resolution | Use GET /sites/{hostname}:/{server-relative-path} (Microsoft Learn) |
| List item creation | Use POST /sites/{siteId}/lists/{listId}/items with fields (Microsoft Learn) |
| Ops | Build retries for 429 and log failures (production requirement) |
