Unattended (Client Secret) Console App to Create a SharePoint List Item (End-to-End)

This article shows a complete, step-by-step path from Microsoft Entra ID (Azure AD) app registration to an unattended C# console app that creates a new item in a SharePoint list named UnAttendedLoginList.

We’ll use:

  • Client credentials (app-only) with client secret
  • MSAL (AcquireTokenForClient)
  • Microsoft Graph to create the list item (POST /sites/{site-id}/lists/{list-id}/items) (Microsoft Learn)

Architecture overview

  1. Register an app in Entra ID.
  2. Create a client secret (no certificate required).
  3. Grant application permissions in Microsoft Graph.
  4. (Recommended) Restrict access with Sites.Selected + site permission grant (resource-specific). (Microsoft Learn)
  5. Console app gets an app-only token via MSAL and creates a SharePoint list item via Graph. (Microsoft Learn)

Step 1 — Register the app in Microsoft Entra ID

  1. Go to Microsoft Entra admin centerIdentityApplicationsApp registrations
  2. Click New registration
  3. Use:
    • Name: Unattended-SharePoint-ListWriter
    • Supported account types: Single tenant (typical)
    • Redirect URI: leave empty (not needed for client credentials)
  4. Click Register

✅ Save these values from Overview:

  • Directory (tenant) ID
  • Application (client) ID

Step 2 — Create a client secret (for unattended runs)

  1. App registration → Certificates & secrets
  2. New client secret
  3. Choose expiration (shorter is safer; plan rotation)
  4. Copy the secret value now (you won’t see it again)

A certificate is optional. Client secret is enough for unattended app-only. The tradeoff is secret protection and rotation.


Step 3 — Add Microsoft Graph application permissions

App registration → API permissionsAdd a permissionMicrosoft GraphApplication permissions

Recommended (least privilege)

  • Sites.Selected

Then click Grant admin consent. (Microsoft Learn)

Simpler (but tenant-wide)

  • Sites.ReadWrite.All

Also requires Grant admin consent.


Step 4 — (If using Sites.Selected) grant the app access to the site

With Sites.Selected, the app has no access until you explicitly grant it to a site (resource-specific consent concept). (Microsoft Learn)

4.1 Resolve the site ID (one time)

Example (replace with your hostname + site path):

GET

https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/ExampleSite

Graph supports addressing a SharePoint site by hostname + server-relative path. (Microsoft Learn)

4.2 Grant write permission to the app on that site (one time)

POST

https://graph.microsoft.com/v1.0/sites/{site-id}/permissions

Body:

{
  "roles": ["write"],
  "grantedToIdentities": [
    {
      "application": {
        "id": "YOUR-CLIENT-ID",
        "displayName": "Unattended-SharePoint-ListWriter"
      }
    }
  ]
}

Practical note: Many teams do this with Graph Explorer or Graph PowerShell while signed in as admin.


Step 5 — Create the console app

5.1 Create project + install MSAL

dotnet new console -n UnattendedListWriter
cd UnattendedListWriter
dotnet add package Microsoft.Identity.Client

5.2 Create appsettings.json

{
  "TenantId": "YOUR_TENANT_ID",
  "ClientId": "YOUR_CLIENT_ID",
  "ClientSecret": "YOUR_CLIENT_SECRET",

  "SiteHostName": "contoso.sharepoint.com",
  "SitePath": "/sites/ExampleSite",

  "ListDisplayName": "UnAttendedLoginList",
  "ItemTitle": "Created by unattended console app"
}

5.3 Full Program.cs (copy/paste)

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; } = "UnAttendedLoginList";
    public string ItemTitle { get; set; } = "Created by unattended console app";
}

internal static class Program
{
    // For client credentials, request {resource}/.default
    // This tells Entra ID to use the app's *application* permissions. :contentReference[oaicite:6]{index=6}
    private static readonly string[] GraphScopes = new[] { "https://graph.microsoft.com/.default" };

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

            Console.WriteLine("=== Unattended List Writer (Graph + Client Secret) ===");

            var token = await AcquireGraphTokenAsync(cfg);
            Console.WriteLine("Token acquired.");

            using (var http = new HttpClient())
            {
                http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

                var siteId = await GetSiteIdAsync(http, cfg.SiteHostName, cfg.SitePath);
                Console.WriteLine("SiteId: " + siteId);

                var listId = await GetListIdByDisplayNameAsync(http, siteId, cfg.ListDisplayName);
                Console.WriteLine("ListId: " + listId);

                var createdId = await CreateListItemAsync(http, siteId, listId, cfg.ItemTitle);
                Console.WriteLine("SUCCESS: Created item id: " + createdId);
            }

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

    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.");

        return cfg;
    }

    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.");
    }

    private static async Task<string> AcquireGraphTokenAsync(AppConfig cfg)
    {
        // Client credentials flow (no user) uses AcquireTokenForClient. :contentReference[oaicite:7]{index=7}
        var app = ConfidentialClientApplicationBuilder
            .Create(cfg.ClientId)
            .WithClientSecret(cfg.ClientSecret)
            .WithAuthority($"https://login.microsoftonline.com/{cfg.TenantId}")
            .Build();

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

    private static async Task<string> GetSiteIdAsync(HttpClient http, string hostName, string sitePath)
    {
        // GET /sites/{hostname}:{server-relative-path}
        var url = $"https://graph.microsoft.com/v1.0/sites/{hostName}:{sitePath}";
        var json = await SendAsync(http, HttpMethod.Get, url, null);

        using (var doc = JsonDocument.Parse(json))
        {
            if (!doc.RootElement.TryGetProperty("id", out var idProp))
                throw new InvalidOperationException("Site response missing 'id'. Response: " + json);

            return idProp.GetString() ?? throw new InvalidOperationException("Site id was null.");
        }
    }

    private static async Task<string> GetListIdByDisplayNameAsync(HttpClient http, string siteId, string listDisplayName)
    {
        var filter = Uri.EscapeDataString($"displayName eq '{listDisplayName.Replace("'", "''")}'");
        var url = $"https://graph.microsoft.com/v1.0/sites/{siteId}/lists?$filter={filter}";
        var json = await SendAsync(http, HttpMethod.Get, url, null);

        using (var doc = JsonDocument.Parse(json))
        {
            if (!doc.RootElement.TryGetProperty("value", out var valueProp) || valueProp.GetArrayLength() == 0)
                throw new InvalidOperationException($"List '{listDisplayName}' not found. Response: " + json);

            var first = valueProp[0];
            if (!first.TryGetProperty("id", out var idProp))
                throw new InvalidOperationException("List response missing 'id'. Response: " + json);

            return idProp.GetString() ?? throw new InvalidOperationException("List id was null.");
        }
    }

    private static async Task<string> CreateListItemAsync(HttpClient http, string siteId, string listId, string title)
    {
        // POST /sites/{site-id}/lists/{list-id}/items with { fields: { Title: "..." } } :contentReference[oaicite:8]{index=8}
        var url = $"https://graph.microsoft.com/v1.0/sites/{siteId}/lists/{listId}/items";

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

        var body = JsonSerializer.Serialize(payload);
        var json = await SendAsync(http, HttpMethod.Post, url, body);

        using (var doc = JsonDocument.Parse(json))
        {
            return doc.RootElement.TryGetProperty("id", out var idProp)
                ? (idProp.GetString() ?? "(null id)")
                : "(created but id not returned)";
        }
    }

    private static async Task<string> SendAsync(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 6 — Run it

dotnet run

If everything is correct, you’ll see:

  • token acquired
  • site id resolved
  • list id resolved
  • item created

Troubleshooting quick hits

  • 403 Forbidden
    • If using Sites.Selected, you likely forgot the site permission grant step. (Microsoft Learn)
  • List not found
    • Confirm the list display name is exactly UnAttendedLoginList.
  • Field errors
    • Graph uses SharePoint field internal names inside fields. Title is standard and usually exists. (Microsoft Learn)

Optional note: SharePoint REST alternative (not required)

You can also create list items using SharePoint REST (/_api/web/lists/getbytitle('...')/items). This is a valid approach, but for app-only modern permissioning, the Graph approach is typically simpler to operationalize and secure with Sites.Selected. (Microsoft Learn)


Summary table — Step-by-step

StepWhat you doResult
1Register app in Entra IDTenantId + ClientId
2Create client secretUnattended credential
3Add Graph App permission + admin consentApp authorized
4(Sites.Selected) Grant site accessApp can write to that site only
5Build console app (MSAL + Graph)Ready-to-run tool
6RunItem created in UnAttendedLoginList

Summary table — Technical mapping

TopicChoiceWhy
Unattended authClient credentials + secretNo user, runs in services/schedulers (Microsoft Learn)
Token APIAcquireTokenForClientCorrect MSAL method for daemon/app-only (Microsoft Learn)
Permissions scopehttps://graph.microsoft.com/.defaultStandard for client credentials + app perms (GitHub)
Create list itemPOST /sites/{site-id}/lists/{list-id}/itemsOfficial Graph method (Microsoft Learn)

Edvaldo Guimrães Filho Avatar

Published by