Unattended SharePoint List Writer (Client Secret + Microsoft Graph) — Full Step-by-Step Guide (Microsoft Learn referenced)
This article documents a working pattern to create a SharePoint list item unattended (no user interaction) using:
- Microsoft Entra ID (Azure AD) App Registration
- Client secret (no certificate required)
- MSAL (
AcquireTokenForClient) - Microsoft Graph (
POST /sites/{site-id}/lists/{list-id}/items)
Everything below is aligned with Microsoft Learn documentation. (Microsoft Learn)
What you are building
A .NET console app that:
- Loads configuration from
appsettings.json - Requests an app-only access token using OAuth 2.0 client credentials flow
- Resolves the SharePoint Site ID from hostname + server-relative path
- Resolves the List ID by list display name
- Creates a list item by posting JSON
{ fields: { Title: "..." } }
Microsoft Learn references:
- App-only (no user) access (client credentials) (Microsoft Learn)
.defaultscope required for client credentials (Microsoft Learn)- Get site by path (Microsoft Learn)
- List lists in a site (Microsoft Learn)
- Create list item (Microsoft Learn)
Step 1 — Register the app in Microsoft Entra ID (Azure AD)
- Open Microsoft Entra admin center
- Go to Identity → Applications → App registrations
- Click New registration
- Use:
- Name:
Unattended-SharePoint-ListWriter - Supported account types: Single tenant (typical for internal apps)
- Redirect URI: leave empty (not needed for app-only)
- Name:
- Click Register
Save:
- Tenant ID (Directory ID)
- Client ID (Application ID)
This app will run as a confidential client (it holds credentials such as a secret/certificate) using client credentials flow. (Microsoft Learn)
Step 2 — Create a client secret (unattended credential)
- App registration → Certificates & secrets
- New client secret
- Choose an expiration
- Copy the secret value and store it securely
A certificate is optional; client secret is valid for client credentials flow. The key is that your app is a confidential client and can authenticate without user interaction. (Microsoft Learn)
Step 3 — Add Microsoft Graph Application Permissions (this is where your 403 came from)
Because you are calling Microsoft Graph, you must add Graph permissions (not SharePoint API permissions).
- App registration → API permissions
- Add a permission
- Choose Microsoft Graph
- Choose Application permissions
- Add:
- Sites.ReadWrite.All (Application)
- Click Grant admin consent
Microsoft Learn explains:
- Apps must be granted the right permissions and consent before Graph will authorize operations. (Microsoft Learn)
- The “access without a user” (app-only) scenario requires application permissions + admin consent. (Microsoft Learn)
Step 4 — Create the console app (Visual Studio)
4.1 Create project
- Visual Studio → Create a new project → Console App (.NET)
4.2 Install MSAL
NuGet package:
Microsoft.Identity.Client
MSAL is used to implement the client credentials token acquisition logic in .NET. (Microsoft Learn)
Step 5 — Add appsettings.json
Create appsettings.json:
{
"TenantId": "YOUR_TENANT_ID",
"ClientId": "YOUR_CLIENT_ID",
"ClientSecret": "YOUR_CLIENT_SECRET",
"SiteHostName": "contoso.sharepoint.com",
"SitePath": "/sites/ExampleSite",
"ListDisplayName": "Example List",
"ItemTitle": "Created by unattended console app"
}
Important (Visual Studio file property):
- Click
appsettings.json - Properties → Copy to Output Directory → Copy if newer
Step 6 — Full working code (with step-by-step explanation)
Below is the same code structure you used (MSAL + HttpClient + Graph). I’m keeping it as-is but with neutral placeholders in the config defaults.
Program.cs
using System;
using System.IO;
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 class AppConfig
{
public string TenantId { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string SiteHostName { get; set; } = "";
public string SitePath { get; set; } = "";
public string ListDisplayName { get; set; } = "Example List";
public string ItemTitle { get; set; } = "Created by unattended console app";
}
internal static class Program
{
// Client credentials uses the ".default" scope for Microsoft Graph application permissions.
// Microsoft Learn: client credentials requests must include scope={resource}/.default. :contentReference[oaicite:11]{index=11}
private static readonly string[] GraphScopes = new[] { "https://graph.microsoft.com/.default" };
public static async Task<int> Main()
{
try
{
var config = LoadConfig("appsettings.json");
ValidateConfig(config);
Console.WriteLine("=== Unattended SharePoint List Writer (Graph + Client Secret) ===");
// 1) Acquire an app-only token using the OAuth2 client credentials flow (no user).
// Microsoft Learn: app-only access via client credentials and MSAL. :contentReference[oaicite:12]{index=12}
var token = await AcquireGraphTokenAsync(config);
Console.WriteLine("Access token acquired.");
using (var http = new HttpClient())
{
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// 2) Resolve site-id by hostname + server-relative path.
// Microsoft Learn: GET /sites/{hostname}:/{server-relative-path} :contentReference[oaicite:13]{index=13}
var siteId = await GetSiteIdAsync(http, config.SiteHostName, config.SitePath);
Console.WriteLine("SiteId: " + siteId);
// 3) Resolve list-id by list displayName.
// Microsoft Learn: lists exist under /sites/{site-id}/lists :contentReference[oaicite:14]{index=14}
var listId = await GetListIdByDisplayNameAsync(http, siteId, config.ListDisplayName);
Console.WriteLine("ListId: " + listId);
// 4) Create a list item (POST /items) with fields payload.
// Microsoft Learn: listItem create endpoint and payload format. :contentReference[oaicite:15]{index=15}
var createdItemId = await CreateListItemAsync(http, siteId, listId, config.ItemTitle);
Console.WriteLine("SUCCESS: Created item with ID: " + createdItemId);
}
return 0;
}
catch (MsalServiceException ex)
{
Console.WriteLine("MSAL service error:");
Console.WriteLine(ex.Message);
Console.WriteLine("StatusCode: " + ex.StatusCode);
return 2;
}
catch (HttpRequestException ex)
{
Console.WriteLine("HTTP error:");
Console.WriteLine(ex.Message);
return 3;
}
catch (Exception ex)
{
Console.WriteLine("Unhandled error:");
Console.WriteLine(ex);
return 1;
}
}
// Reads appsettings.json from disk.
private static AppConfig LoadConfig(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException("Config file not found: " + path);
var json = File.ReadAllText(path);
var cfg = JsonSerializer.Deserialize<AppConfig>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (cfg == null)
throw new InvalidOperationException("Failed to parse config file: " + path);
return cfg;
}
// Defensive validation to fail fast with a clear error.
private static void ValidateConfig(AppConfig cfg)
{
if (string.IsNullOrWhiteSpace(cfg.TenantId)) throw new ArgumentException("TenantId is required.");
if (string.IsNullOrWhiteSpace(cfg.ClientId)) throw new ArgumentException("ClientId is required.");
if (string.IsNullOrWhiteSpace(cfg.ClientSecret)) throw new ArgumentException("ClientSecret is required.");
if (string.IsNullOrWhiteSpace(cfg.SiteHostName)) throw new ArgumentException("SiteHostName is required.");
if (string.IsNullOrWhiteSpace(cfg.SitePath)) throw new ArgumentException("SitePath is required.");
if (!cfg.SitePath.StartsWith("/")) throw new ArgumentException("SitePath must start with '/'. Example: /sites/ExampleSite");
if (string.IsNullOrWhiteSpace(cfg.ListDisplayName)) throw new ArgumentException("ListDisplayName is required.");
}
// Uses MSAL to acquire an app-only token (client credentials).
private static async Task<string> AcquireGraphTokenAsync(AppConfig cfg)
{
var app = ConfidentialClientApplicationBuilder
.Create(cfg.ClientId)
.WithClientSecret(cfg.ClientSecret)
.WithAuthority($"https://login.microsoftonline.com/{cfg.TenantId}")
.Build();
// Microsoft Learn: client credentials flow in MSAL for daemon apps. :contentReference[oaicite:16]{index=16}
var result = await app.AcquireTokenForClient(GraphScopes).ExecuteAsync();
return result.AccessToken;
}
// Gets Site ID by hostname + server-relative path.
private static async Task<string> GetSiteIdAsync(HttpClient http, string hostName, string sitePath)
{
// Microsoft Learn: Get site by path :contentReference[oaicite:17]{index=17}
var url = $"https://graph.microsoft.com/v1.0/sites/{hostName}:{sitePath}";
var json = await SendAndReadAsync(http, HttpMethod.Get, url, body: null);
using (var doc = JsonDocument.Parse(json))
{
if (!doc.RootElement.TryGetProperty("id", out var idProp))
throw new InvalidOperationException("Could not find 'id' in site response. Response: " + json);
return idProp.GetString() ?? throw new InvalidOperationException("Site 'id' was null.");
}
}
// Finds the list by displayName and returns its list-id.
private static async Task<string> GetListIdByDisplayNameAsync(HttpClient http, string siteId, string listDisplayName)
{
// Lists are under /sites/{site-id}/lists. :contentReference[oaicite:18]{index=18}
var filter = Uri.EscapeDataString($"displayName eq '{listDisplayName.Replace("'", "''")}'");
var url = $"https://graph.microsoft.com/v1.0/sites/{siteId}/lists?$filter={filter}";
var json = await SendAndReadAsync(http, HttpMethod.Get, url, body: null);
using (var doc = JsonDocument.Parse(json))
{
if (!doc.RootElement.TryGetProperty("value", out var valueProp) || valueProp.GetArrayLength() == 0)
throw new InvalidOperationException($"List not found by displayName='{listDisplayName}'. Response: " + json);
var first = valueProp[0];
if (!first.TryGetProperty("id", out var idProp))
throw new InvalidOperationException("Could not find list 'id'. Response: " + json);
return idProp.GetString() ?? throw new InvalidOperationException("List 'id' was null.");
}
}
// Creates a list item with fields payload.
private static async Task<string> CreateListItemAsync(HttpClient http, string siteId, string listId, string title)
{
// Microsoft Learn: POST /sites/{site-id}/lists/{list-id}/items :contentReference[oaicite:19]{index=19}
var url = $"https://graph.microsoft.com/v1.0/sites/{siteId}/lists/{listId}/items";
// Microsoft Learn example uses "fields" object (Title + other columns). :contentReference[oaicite:20]{index=20}
var payload = new
{
fields = new
{
Title = title
}
};
var body = JsonSerializer.Serialize(payload);
var json = await SendAndReadAsync(http, HttpMethod.Post, url, body);
using (var doc = JsonDocument.Parse(json))
{
if (!doc.RootElement.TryGetProperty("id", out var idProp))
return "(created, but could not read returned id)";
return idProp.GetString() ?? "(id was null)";
}
}
// Common HTTP helper: sends request, validates status code, returns response body.
private static async Task<string> SendAndReadAsync(HttpClient http, HttpMethod method, string url, string? body)
{
using (var req = new HttpRequestMessage(method, url))
{
if (body != null)
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
using (var resp = await http.SendAsync(req))
{
var content = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"Graph call failed: {(int)resp.StatusCode} {resp.ReasonPhrase}\nURL: {url}\nResponse: {content}");
}
return content;
}
}
}
}
Step 7 — Run the app
Run from Visual Studio (Ctrl+F5) or CLI:
dotnet run
Expected output:
- Token acquired
- SiteId resolved
- ListId resolved
- Item created successfully
Troubleshooting (based on Microsoft Learn)
403 Access Denied
Most common causes:
- Missing Microsoft Graph Application permission and/or missing admin consent (Microsoft Learn)
- Token request is not using
{resource}/.defaultfor client credentials (Microsoft Learn) - You’re granted read-only permissions but attempting write operations (needs write scope/role) (Microsoft Learn)
“Config file not found”
Set appsettings.json → Copy to Output Directory = Copy if newer.
List not found
- Confirm your
ListDisplayNamematches the list name you see in SharePoint. - If your list name includes special characters, make sure it is typed exactly.
Summary table — Steps
| Step | What you do | Output |
|---|---|---|
| 1 | Register Entra ID app | TenantId + ClientId |
| 2 | Create client secret | Unattended credential |
| 3 | Add Graph Application permission + admin consent | App authorized for Graph (Microsoft Learn) |
| 4 | Create console project + install MSAL | Buildable app |
| 5 | Add appsettings.json | Configurable runtime |
| 6 | Run: token → siteId → listId → create item | New list item created (Microsoft Learn) |
Summary table — Technical mapping (Microsoft Learn)
| Topic | What we used | Microsoft Learn reference |
|---|---|---|
| App-only authentication | OAuth2 client credentials | (Microsoft Learn) |
.default scope | https://graph.microsoft.com/.default | (Microsoft Learn) |
| Site resolution | GET /sites/{host}:{path} | (Microsoft Learn) |
| Lists under site | GET /sites/{site-id}/lists | (Microsoft Learn) |
| Create list item | POST /sites/{site-id}/lists/{list-id}/items + fields payload | (Microsoft Learn) |
| Permission model | Graph permissions + admin consent | (Microsoft Learn) |
