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:

  1. Loads configuration from appsettings.json
  2. Requests an app-only access token using OAuth 2.0 client credentials flow
  3. Resolves the SharePoint Site ID from hostname + server-relative path
  4. Resolves the List ID by list display name
  5. Creates a list item by posting JSON { fields: { Title: "..." } }

Microsoft Learn references:


Step 1 — Register the app in Microsoft Entra ID (Azure AD)

  1. Open Microsoft Entra admin center
  2. Go to Identity → Applications → App registrations
  3. Click New registration
  4. Use:
    • Name: Unattended-SharePoint-ListWriter
    • Supported account types: Single tenant (typical for internal apps)
    • Redirect URI: leave empty (not needed for app-only)
  5. 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)

  1. App registration → Certificates & secrets
  2. New client secret
  3. Choose an expiration
  4. 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).

  1. App registration → API permissions
  2. Add a permission
  3. Choose Microsoft Graph
  4. Choose Application permissions
  5. Add:
    • Sites.ReadWrite.All (Application)
  6. 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 projectConsole 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 DirectoryCopy 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:

  1. Missing Microsoft Graph Application permission and/or missing admin consent (Microsoft Learn)
  2. Token request is not using {resource}/.default for client credentials (Microsoft Learn)
  3. You’re granted read-only permissions but attempting write operations (needs write scope/role) (Microsoft Learn)

“Config file not found”

Set appsettings.jsonCopy to Output Directory = Copy if newer.

List not found

  • Confirm your ListDisplayName matches the list name you see in SharePoint.
  • If your list name includes special characters, make sure it is typed exactly.

Summary table — Steps

StepWhat you doOutput
1Register Entra ID appTenantId + ClientId
2Create client secretUnattended credential
3Add Graph Application permission + admin consentApp authorized for Graph (Microsoft Learn)
4Create console project + install MSALBuildable app
5Add appsettings.jsonConfigurable runtime
6Run: token → siteId → listId → create itemNew list item created (Microsoft Learn)

Summary table — Technical mapping (Microsoft Learn)

TopicWhat we usedMicrosoft Learn reference
App-only authenticationOAuth2 client credentials(Microsoft Learn)
.default scopehttps://graph.microsoft.com/.default(Microsoft Learn)
Site resolutionGET /sites/{host}:{path}(Microsoft Learn)
Lists under siteGET /sites/{site-id}/lists(Microsoft Learn)
Create list itemPOST /sites/{site-id}/lists/{list-id}/items + fields payload(Microsoft Learn)
Permission modelGraph permissions + admin consent(Microsoft Learn)

Edvaldo Guimrães Filho Avatar

Published by