Connect & Query SharePoint Online from C# (.NET) with Interactive Login — Get List Item by Title
This post shows how to build a fresh, minimal .NET console app that:
- Authenticates interactively with Entra ID (Azure AD) using MSAL, and
- Fetches a list item by Title from SharePoint Online using the classic REST API.
No SDK lock-in, no tenant names hardcoded. The scope is derived from your siteUrl so this template is safe to reuse across clients/tenants.
What you’ll build
- Project name:
SpListByTitle - MSAL interactive sign-in (public client)
- Dynamic SharePoint scope built from
siteUrl→https://<tenant>.sharepoint.com/AllSites.FullControl - REST query:
/_api/web/lists/getbytitle('<List>')/items?$filter=Title eq '<value>'&$top=1 - Console output: Id, Title, Created, Modified, Author, Editor
Prerequisites
- .NET SDK 6.0+ (8.0 recommended):
dotnet --version - An App Registration in Entra ID configured as a Public client (native)
- Add platform Mobile and desktop applications
- Redirect URI:
http://localhost - Enable public client flows
- A user who can access the target SharePoint site
This template uses delegated permissions via interactive sign-in. Consent may be required depending on your tenant policy.
1) Create the project
dotnet new console -n SpListByTitle
cd SpListByTitle
dotnet add package Microsoft.Identity.Client
2) Full source code (single file)
Replace Program.cs with the code below.
Fill in: tenantId, clientId, siteUrl, listTitle.
Run via: dotnet run -- "Exact Title Here"
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Identity.Client;
namespace SpListByTitle
{
internal class Program
{
// ========================= USER SETTINGS =========================
// Entra ID (Azure AD)
private static readonly string tenantId = "YOUR_TENANT_ID_GUID";
private static readonly string clientId = "YOUR_PUBLIC_CLIENT_APP_ID_GUID";
// SharePoint target (authority comes from this URL)
private static readonly string siteUrl = "https://contoso.sharepoint.com/sites/YourSite";
private static readonly string listTitle = "Your List Display Name";
// =================================================================
static async Task<int> Main(string[] args)
{
Console.WriteLine("=== SharePoint: Connect + Get Item by Title ===");
try
{
// Ask for Title (CLI arg or prompt)
string titleToFind = args.Length > 0 ? args[0] : Ask("Title to search: ");
// Build tenant-agnostic scope from siteUrl (e.g., https://contoso.sharepoint.com/AllSites.FullControl)
var scopes = BuildScopesFromSite(siteUrl);
// 1) Acquire token (interactive)
var accessToken = await AcquireAccessTokenInteractiveAsync(tenantId, clientId, scopes);
if (string.IsNullOrWhiteSpace(accessToken))
{
Console.WriteLine("Failed to acquire token.");
return 1;
}
// (Optional) quick connectivity probe (prints site title)
var siteName = await GetSiteTitleAsync(siteUrl, accessToken);
Console.WriteLine($"Connected to site: {siteName ?? "(unknown)"}");
// 2) Fetch the first item where Title == titleToFind
var item = await GetFirstItemByTitleAsync(siteUrl, listTitle, titleToFind, accessToken);
if (item is null)
{
Console.WriteLine("No item found with that Title.");
return 0;
}
// 3) Pretty-print selected fields
PrintItemToConsole(item.Value);
return 0;
}
catch (Exception ex)
{
Console.WriteLine("Error: " + ex.Message);
Console.WriteLine(ex);
return 2;
}
}
// ---------------------------- Auth ----------------------------
private static async Task<string> AcquireAccessTokenInteractiveAsync(
string tenantId,
string clientId,
string[] scopes)
{
var app = PublicClientApplicationBuilder.Create(clientId)
.WithAuthority(AzureCloudInstance.AzurePublic, tenantId)
.WithRedirectUri("http://localhost")
.Build();
try
{
var result = await app.AcquireTokenInteractive(scopes)
.WithPrompt(Prompt.SelectAccount)
.ExecuteAsync();
return result.AccessToken;
}
catch (MsalException mex)
{
Console.WriteLine("MSAL error: " + mex.Message);
return string.Empty;
}
}
// ---------------------- Connectivity Probe ---------------------
private static async Task<string?> GetSiteTitleAsync(string siteUrl, string accessToken)
{
using (var http = new HttpClient())
{
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
http.DefaultRequestHeaders.Accept.Clear();
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var endpoint = $"{TrimEndSlash(siteUrl)}/_api/web?$select=Title";
using (var req = new HttpRequestMessage(HttpMethod.Get, endpoint))
{
req.Headers.Add("Accept", "application/json;odata=nometadata");
using (var res = await http.SendAsync(req))
{
var body = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new Exception($"SharePoint error {(int)res.StatusCode}: {res.ReasonPhrase}\n{body}");
using (var doc = JsonDocument.Parse(body))
{
if (doc.RootElement.TryGetProperty("Title", out var t))
return t.GetString();
}
}
}
}
return null;
}
// ---------------------- List Item by Title ---------------------
private static async Task<JsonElement?> GetFirstItemByTitleAsync(
string siteUrl,
string listDisplayName,
string titleValue,
string accessToken)
{
using (var http = new HttpClient())
{
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
http.DefaultRequestHeaders.Accept.Clear();
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Escape single quotes for OData string literals
string escapedTitleLiteral = (titleValue ?? string.Empty).Replace("'", "''");
string endpoint =
$"{TrimEndSlash(siteUrl)}/_api/web/lists/getbytitle('{Uri.EscapeDataString(listDisplayName)}')" +
$"/items?$filter=Title eq '{escapedTitleLiteral}'" +
$"&$select=Id,Title,Created,Modified,Author/Title,Editor/Title" +
$"&$expand=Author,Editor&$top=1";
using (var req = new HttpRequestMessage(HttpMethod.Get, endpoint))
{
req.Headers.Add("Accept", "application/json;odata=nometadata");
using (var res = await http.SendAsync(req))
{
var body = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new Exception($"SharePoint error {(int)res.StatusCode}: {res.ReasonPhrase}\n{body}");
using (var doc = JsonDocument.Parse(body))
{
// odata=nometadata shape: { "value": [ ... ] }
if (!doc.RootElement.TryGetProperty("value", out var arr) || arr.GetArrayLength() == 0)
return null;
return arr[0];
}
}
}
}
}
// --------------------------- Helpers --------------------------
private static string[] BuildScopesFromSite(string siteUrl)
{
var resource = new Uri(siteUrl).GetLeftPart(UriPartial.Authority); // e.g., https://contoso.sharepoint.com
return new[] { $"{resource}/AllSites.FullControl" };
}
private static string TrimEndSlash(string url)
{
if (string.IsNullOrWhiteSpace(url)) return url;
return url.EndsWith("/") ? url.TrimEnd('/') : url;
}
private static void PrintItemToConsole(JsonElement item)
{
string GetStr(string name)
{
return item.TryGetProperty(name, out var v) && v.ValueKind != JsonValueKind.Null
? v.ToString()
: string.Empty;
}
string id = GetStr("Id");
string title = GetStr("Title");
string created = GetStr("Created");
string modified = GetStr("Modified");
string author = item.TryGetProperty("Author", out var a) && a.ValueKind == JsonValueKind.Object
&& a.TryGetProperty("Title", out var aTitle)
? aTitle.ToString()
: string.Empty;
string editor = item.TryGetProperty("Editor", out var e) && e.ValueKind == JsonValueKind.Object
&& e.TryGetProperty("Title", out var eTitle)
? eTitle.ToString()
: string.Empty;
Console.WriteLine("-------------------------------------------------");
Console.WriteLine($"Id : {id}");
Console.WriteLine($"Title : {title}");
Console.WriteLine($"Created : {created}");
Console.WriteLine($"Modified : {modified}");
Console.WriteLine($"Author : {author}");
Console.WriteLine($"Editor : {editor}");
Console.WriteLine("-------------------------------------------------");
}
private static string Ask(string prompt)
{
Console.Write(prompt);
return Console.ReadLine() ?? string.Empty;
}
}
}
3) Run it
dotnet run -- "Exact Title Here"
If you omit the argument, it will prompt:
Title to search:
Expected output (example):
=== SharePoint: Connect + Get Item by Title ===
Connected to site: Projects Portal
-------------------------------------------------
Id : 42
Title : Exact Title Here
Created : 2025-08-12T17:21:36Z
Modified : 2025-09-01T10:15:03Z
Author : Jane Doe
Editor : John Smith
-------------------------------------------------
Customizations (quick)
- All matches instead of first: remove
&$top=1and iterate thevalue[]array. - Contains / StartsWith:
substringof('needle', Title)startswith(Title,'prefix')
Mind performance; add indexes where appropriate.
- More fields: add to
$selectand$expand(e.g.,Editor/Email, custom columns).
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
MSAL error: admin_consent_required | Tenant requires admin consent for delegated SPO scopes | Ask an admin to grant consent or pre-consent the scope |
| Browser returns error page | Redirect URI mismatch | Ensure http://localhost is configured under Mobile and desktop applications |
401 Unauthorized from REST | Wrong scope, missing header, or user lacks access | Keep dynamic scope, check Authorization: Bearer <token>, confirm user/site permissions |
invalid_client | App not configured as public client | Enable public client flows in the app registration |
Title with ' (apostrophes) fails | Unescaped OData literal | The code doubles single quotes; keep that method in place |
Summary — Steps
| # | Step | Command / Action |
|---|---|---|
| 1 | Create console app | dotnet new console -n SpListByTitle |
| 2 | Add MSAL | dotnet add package Microsoft.Identity.Client |
| 3 | Configure | Set tenantId, clientId, siteUrl, listTitle |
| 4 | Scope | Derived from siteUrl → <authority>/AllSites.FullControl |
| 5 | Run | dotnet run -- "Exact Title Here" |
| 6 | Result | Prints first matching item’s key fields |
Summary — Technical
| Topic | Value |
|---|---|
| Auth flow | MSAL interactive (Public client, system browser) |
| Authority | AzurePublic + your tenantId |
| Redirect URI | http://localhost |
| Scope | <siteUrl authority>/AllSites.FullControl (built at runtime) |
| REST endpoint | /_api/web/lists/getbytitle('<List>')/items?$filter=Title eq '...'&$top=1 |
| Headers | Authorization: Bearer <token>, Accept: application/json;odata=nometadata |
| JSON parsing | System.Text.Json (value[0]) |
| Output fields | Id, Title, Created, Modified, Author/Title, Editor/Title |
