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
- Register an app in Entra ID.
- Create a client secret (no certificate required).
- Grant application permissions in Microsoft Graph.
- (Recommended) Restrict access with Sites.Selected + site permission grant (resource-specific). (Microsoft Learn)
- 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
- Go to Microsoft Entra admin center → Identity → Applications → App registrations
- Click New registration
- Use:
- Name:
Unattended-SharePoint-ListWriter - Supported account types: Single tenant (typical)
- Redirect URI: leave empty (not needed for client credentials)
- Name:
- Click Register
✅ Save these values from Overview:
- Directory (tenant) ID
- Application (client) ID
Step 2 — Create a client secret (for unattended runs)
- App registration → Certificates & secrets
- New client secret
- Choose expiration (shorter is safer; plan rotation)
- 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 permissions → Add a permission → Microsoft Graph → Application 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.
- Confirm the list display name is exactly
- Field errors
- Graph uses SharePoint field internal names inside
fields.Titleis standard and usually exists. (Microsoft Learn)
- Graph uses SharePoint field internal names inside
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
| Step | What you do | Result |
|---|---|---|
| 1 | Register app in Entra ID | TenantId + ClientId |
| 2 | Create client secret | Unattended credential |
| 3 | Add Graph App permission + admin consent | App authorized |
| 4 | (Sites.Selected) Grant site access | App can write to that site only |
| 5 | Build console app (MSAL + Graph) | Ready-to-run tool |
| 6 | Run | Item created in UnAttendedLoginList |
Summary table — Technical mapping
| Topic | Choice | Why |
|---|---|---|
| Unattended auth | Client credentials + secret | No user, runs in services/schedulers (Microsoft Learn) |
| Token API | AcquireTokenForClient | Correct MSAL method for daemon/app-only (Microsoft Learn) |
| Permissions scope | https://graph.microsoft.com/.default | Standard for client credentials + app perms (GitHub) |
| Create list item | POST /sites/{site-id}/lists/{list-id}/items | Official Graph method (Microsoft Learn) |
