Live training: next batches coming soon. Join the waitlist:Waitlists open:Developer Training →Developer →Copilot Studio Training →Copilot Studio →

How to Get User Names from Entra ID Using Microsoft Graph in C#

A client asked me for a small Windows tool: pick a person from the company directory, pick a client folder in a SharePoint library, and give that person access to that one folder.

The finished app gets user names from Microsoft Entra ID with Microsoft Graph in C#, shows them in a list and a dropdown, and grants a SharePoint permission level on the folder you choose.

In this tutorial, we’ll build it step by step: a .NET project with the Graph SDK, a token, the /users call with $select, paging, search with ConsistencyLevel, data binding and the folder grant. That’s the full C# path to get user names from Entra ID using Microsoft Graph.

Here’s what we’re building:

C# Windows Forms app listing user names from Microsoft Entra ID with Microsoft Graph and a folder permission dropdown

Before You Start

You need the .NET SDK, a work account (guests can’t call the list users API), a Microsoft Graph read permission, and owner rights on the library. If you’re new to CSOM, skim our SharePoint CSOM guide first.

We only read a few user properties. Here’s what each one is for:

PropertyTypeWhy we read it
displayNameStringThe name people see in the list and dropdown
userPrincipalNameStringThe sign-in name SharePoint needs to add the user
jobTitleStringOptional extra column in search results

Pick the smallest permission that works for your app type:

App typePermissionNotes
Desktop app, signed-in user (delegated)User.ReadBasic.AllLeast privileged; basic profile fields like displayName and userPrincipalName
Desktop app needing more fieldsUser.Read.All (delegated)Full profile; may need admin consent
Background job, no user (application)User.Read.All (application)Always needs admin consent

Our sample folders live in a new SPGDemo Folder Access library on https://spguides.sharepoint.com/sites/SPGuides: Client A – 2026 Contracts and Client B – 2026 Contracts. You can create one the same way with PnP PowerShell.

Azure AD Graph Is Retired: Use Microsoft Graph

Older samples still use the Azure AD Graph API at graph.windows.net. It’s gone: Microsoft announced the deprecation in 2019, blocked new apps after August 31, 2024, and ended extended access on August 31, 2025, when Azure AD Graph was fully retired.

Azure Active Directory itself is now called Microsoft Entra ID, and every user query goes through Microsoft Graph. If you still have old code, swap these pieces (the same move we made when we called Microsoft Graph from SPFx web parts):

Old Azure AD GraphMicrosoft Graph today
https://graph.windows.net/{tenant}/users?api-version=1.6https://graph.microsoft.com/v1.0/users
Microsoft.Azure.ActiveDirectory.GraphClient (.NET)Microsoft.Graph SDK (GraphServiceClient)
Permission: Azure Active Directory Graph > User.Read.AllPermission: Microsoft Graph > User.ReadBasic.All or User.Read.All
Paging with odata.nextLink + api-versionPaging with @odata.nextLink or PageIterator

Step 1: Create the .NET Project and Add the Graph SDK

Our goal here is a console project that can call Microsoft Graph and SharePoint CSOM.

  1. Open a terminal and run the commands below.
  2. Run dotnet list package to confirm the versions.
dotnet new console -n GraphUserNames
cd GraphUserNames
dotnet add package Microsoft.Graph
dotnet add package Microsoft.SharePointOnline.CSOM
dotnet list package output showing Microsoft.Graph 6.9.0 and SharePoint CSOM package references

On 10/24/2026 that gave me Microsoft.Graph 6.9.0. Version 6 only dropped .NET 5 support, so Microsoft’s v5 samples work unchanged. I tested on the .NET 7 SDK; target .NET 8 or later for new projects.

Checkpoint: dotnet list package shows Microsoft.Graph and Microsoft.SharePointOnline.CSOM.

Step 2: Get a Token for Microsoft Graph

Every Graph call needs an access token. A desktop app should use interactive sign-in with delegated permissions.

In your own app, register a public client app in Microsoft Entra ID, add the delegated User.ReadBasic.All permission and the redirect URI http://localhost, then create the client like this:

// dotnet add package Azure.Identity
using Azure.Identity;
using Microsoft.Graph;

var options = new InteractiveBrowserCredentialOptions
{
    TenantId = "YOUR_TENANT_ID",
    ClientId = "YOUR_CLIENT_ID",                 // a public client app registration
    RedirectUri = new Uri("http://localhost")    // must be http://localhost
};
var credential = new InteractiveBrowserCredential(options);
var graphClient = new GraphServiceClient(credential, new[] { "User.ReadBasic.All" });

Skip the client secret from older samples: a secret inside an .exe isn’t secret, and app-only access always needs admin consent.

For this build I reused the PnP PowerShell app that already had consent in my tenant and passed its tokens to C# through environment variables. If PnP isn’t set up yet, follow how to install PnP PowerShell.

Connect-PnPOnline -Url "https://spguides.sharepoint.com/sites/SPGuides" -ClientId "<your PnP app id>" -DeviceLogin -PersistLogin
$env:GRAPH_TOKEN = Get-PnPAccessToken -ResourceTypeName Graph
$env:SPO_TOKEN   = Get-PnPAccessToken -ResourceTypeName SharePoint
$env:SPO_SITE    = "https://spguides.sharepoint.com/sites/SPGuides"
PowerShell console showing a Microsoft Graph access token from Get-PnPAccessToken and its delegated scopes

The C# side wraps that token in a tiny TokenCredential, so the Graph SDK uses it as-is:

var token = Environment.GetEnvironmentVariable("GRAPH_TOKEN")!;
var graphClient = new GraphServiceClient(new StaticTokenCredential(token));

class StaticTokenCredential : TokenCredential
{
    private readonly string _token;
    public StaticTokenCredential(string token) => _token = token;

    public override AccessToken GetToken(TokenRequestContext context, CancellationToken ct)
        => new AccessToken(_token, DateTimeOffset.UtcNow.AddMinutes(50));

    public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext context, CancellationToken ct)
        => new ValueTask<AccessToken>(GetToken(context, ct));
}

Checkpoint: The token’s audience is https://graph.microsoft.com and its scopes include a user-read permission (mine showed User.ReadWrite.All, which covers reading).

Step 3: Call GET /users with $select and $top

Here’s the raw request the SDK sends:

GET https://graph.microsoft.com/v1.0/users?$select=displayName,userPrincipalName&$top=8
Authorization: Bearer <token>

$select trims the payload and $top sets the page size (default 100, max 999). $skip isn’t supported; you page with @odata.nextLink.

To see paging and search together, I ran this request with Invoke-PnPGraphMethod, which sends any Graph URL with the PnP token:

$url = 'v1.0/users?$search="displayName:M"&$select=displayName,jobTitle&$top=3&$count=true'
$r = Invoke-PnPGraphMethod -Url $url -Method Get -AdditionalHeaders @{ ConsistencyLevel = 'eventual' }
$r.'@odata.count'
$r.value | Format-Table displayName, jobTitle
$r.'@odata.nextLink'
Invoke-PnPGraphMethod result with @odata.count 5, three user names and an @odata.nextLink for the next page

Trimmed, the JSON behind that output looks like this:

{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users(displayName,jobTitle)",
  "@odata.count": 5,
  "value": [
    { "displayName": "Client Meetings", "jobTitle": null },
    { "displayName": "Bijay Kumar [Microsoft MVP]", "jobTitle": "Founder" },
    { "displayName": "Miller", "jobTitle": "Power Platform Engineer" }
  ],
  "@odata.nextLink": "https://graph.microsoft.com/v1.0/users?$search=%22displayName%3aM%22&$select=displayName%2cjobTitle&$top=3&$count=true&$skiptoken=..."
}

Checkpoint: @odata.count is 5, the page holds 3 users, and @odata.nextLink points to the rest.

Step 4: List Every User with PageIterator

The SDK’s PageIterator follows @odata.nextLink until the pages run out. I set the page size to 8 to force several pages:

async Task ListAllUsers()
{
    var firstPage = await graphClient.Users.GetAsync(config =>
    {
        config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" };
        config.QueryParameters.Top = 8;
    });

    int page = 1, count = 0;
    Console.WriteLine($"--- Page {page} ---");
    var iterator = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
        graphClient,
        firstPage!,
        user =>
        {
            count++;
            if (count > 1 && count % 8 == 1) Console.WriteLine($"--- Page {++page} ---");
            Console.WriteLine($"{count,2}. {user.DisplayName,-30} {user.UserPrincipalName}");
            return true;   // keep going
        });
    await iterator.IterateAsync();
    Console.WriteLine($"Total users: {count} on {page} pages");
}

Run it with dotnet run -- list:

C# console output listing all Entra ID user names across three pages with PageIterator, private names masked

My tenant returned 22 users on 3 pages. I masked staff names and the tenant domain in the screenshot; demo accounts like Miller and Mike stay visible.

If you’d rather control each request yourself, loop on OdataNextLink with WithUrl(). This version returned the same result in my test (22 names in 3 requests):

async Task ListAllUsersManually()
{
    var page = await graphClient.Users.GetAsync(config =>
    {
        config.QueryParameters.Select = new[] { "displayName" };
        config.QueryParameters.Top = 8;
    });
    var names = new List<string>();
    int requests = 1;
    while (page?.Value != null)
    {
        names.AddRange(page.Value.Select(u => u.DisplayName ?? ""));
        if (string.IsNullOrEmpty(page.OdataNextLink)) break;
        page = await graphClient.Users.WithUrl(page.OdataNextLink).GetAsync();
        requests++;
    }
    Console.WriteLine($"{names.Count} names in {requests} requests");
}

Checkpoint: The total matches the user count in the Microsoft Entra admin center, and the last page has no next link.

Step 5: Search and Filter Names (ConsistencyLevel: eventual)

Let people type a few letters instead of loading thousands of names. $search matches words that start with the text, so M finds Mike and Client Meetings. It needs the ConsistencyLevel: eventual header and $count=true:

GET https://graph.microsoft.com/v1.0/users?$search="displayName:M"&$select=displayName,jobTitle&$top=3&$count=true
Authorization: Bearer <token>
ConsistencyLevel: eventual

Here’s the same search in C#, plus GET /users/$count for the directory total:

async Task SearchUsers(string text)
{
    var result = await graphClient.Users.GetAsync(config =>
    {
        config.QueryParameters.Search = $"\"displayName:{text}\"";
        config.QueryParameters.Select = new[] { "displayName", "jobTitle" };
        config.QueryParameters.Orderby = new[] { "displayName" };
        config.QueryParameters.Count = true;
        config.Headers.Add("ConsistencyLevel", "eventual");
    });

    Console.WriteLine($"Matches for '{text}': {result?.OdataCount}");
    foreach (var user in result?.Value ?? new List<User>())
        Console.WriteLine($"  {user.DisplayName,-30} {user.JobTitle}");
}

async Task CountUsers()
{
    var total = await graphClient.Users.Count.GetAsync(config =>
        config.Headers.Add("ConsistencyLevel", "eventual"));
    Console.WriteLine($"Users in the directory: {total}");
}
C# console output of a Microsoft Graph $search for M with five matches and a total user count of 22

Simple filters don’t need the header. This request returned Miller and Mike without it:

GET https://graph.microsoft.com/v1.0/users?$filter=startswith(displayName,'Mi')&$select=displayName
QueryConsistencyLevel + $count needed?
$filter=startswith(displayName,’Mi’)No
$search=”displayName:M”Yes
$filter plus $orderby, or endswith()Yes (advanced query)
GET /users/$countConsistencyLevel: eventual

Checkpoint: The search shows Matches for ‘M’: 5, sorted by display name, and the count line reads 22.

Step 6: Bind the Names to a Dropdown and a List

The form has a search box, a ListBox and a ComboBox. Bind the Graph User objects directly: DisplayMember shows the name, ValueMember keeps the UPN.

private async Task LoadUsersAsync(string text)
{
    lblStatus.Text = "Loading users from Microsoft Graph...";
    var result = await graphClient.Users.GetAsync(config =>
    {
        config.QueryParameters.Search = $"\"displayName:{text}\"";
        config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" };
        config.QueryParameters.Orderby = new[] { "displayName" };
        config.QueryParameters.Count = true;
        config.Headers.Add("ConsistencyLevel", "eventual");
    });
    var users = result?.Value ?? new List<User>();

    lstUsers.DataSource = users.ToList();          // its own copy, so the two controls select independently
    lstUsers.DisplayMember = nameof(User.DisplayName);

    cboUser.DataSource = users.ToList();
    cboUser.DisplayMember = nameof(User.DisplayName);
    cboUser.ValueMember = nameof(User.UserPrincipalName);
    cboUser.SelectedIndex = users.FindIndex(u => u.DisplayName == "Miller");

    lblStatus.Text = $"{users.Count} users found for \"{text}\"";
}

Each control gets its own copy of the list. If both share one list, WinForms links their selection, and picking a name in the list silently changes the dropdown.

The same idea works in a web part: our SPFx and Microsoft Graph example loads Graph data into Fluent UI controls.

Checkpoint: The form opens with five names for M and Miller selected in the User dropdown, as in the screenshot at the top.

Step 7: Grant the Selected User Access to a SharePoint Folder

SharePoint CSOM takes the UPN from Graph, breaks the folder’s inheritance, adds the user with EnsureUser and assigns a permission level.

using Microsoft.SharePoint.Client;   // Include() extension
using SP = Microsoft.SharePoint.Client;

// Grants one user a permission level on one folder in a SharePoint document library (CSOM)
public static class FolderPermissions
{
    public static void Grant(string siteUrl, string spoToken, string library, string folderName,
                             string userPrincipalName, string roleName)
    {
        using var ctx = new SP.ClientContext(siteUrl);
        ctx.ExecutingWebRequest += (s, e) =>
            e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + spoToken;

        SP.List list = ctx.Web.Lists.GetByTitle(library);
        SP.Folder folder = list.RootFolder.Folders.GetByUrl(folderName);
        SP.ListItem item = folder.ListItemAllFields;

        // Stop inheriting from the library (keep the current permissions as a starting point)
        item.BreakRoleInheritance(copyRoleAssignments: true, clearSubscopes: false);

        // The UPN from Microsoft Graph becomes a SharePoint user on this site
        SP.User user = ctx.Web.EnsureUser(userPrincipalName);
        var roles = new SP.RoleDefinitionBindingCollection(ctx)
        {
            ctx.Web.RoleDefinitions.GetByName(roleName)   // "Read", "Contribute", "Edit" ...
        };
        item.RoleAssignments.Add(user, roles);

        ctx.Load(item.RoleAssignments, ras => ras.Include(
            ra => ra.Member.Title,
            ra => ra.RoleDefinitionBindings.Include(rd => rd.Name)));
        ctx.ExecuteQuery();   // one round trip for everything above

        Console.WriteLine($"Folder '{folderName}' now has unique permissions:");
        foreach (SP.RoleAssignment ra in item.RoleAssignments)
        {
            var levels = string.Join(", ", ra.RoleDefinitionBindings.Select(rd => rd.Name));
            Console.WriteLine($"  {ra.Member.Title,-30} {levels}");
        }
    }
}

BreakRoleInheritance(true, false) copies the library’s permissions first, so owners keep access. server-relative URL errors in CSOM are the usual trip-up if you build folder paths by hand.

For the console test, a helper finds the user by display name in Graph and passes the UPN on:

async Task GrantByName(string displayName, string folderName, string roleName)
{
    var result = await graphClient.Users.GetAsync(config =>
    {
        config.QueryParameters.Filter = $"displayName eq '{displayName.Replace("'", "''")}'";
        config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" };
    });
    var picked = result?.Value?.FirstOrDefault()
        ?? throw new InvalidOperationException($"No user named {displayName}");
    Console.WriteLine($"Graph returned: {picked.DisplayName} ({picked.UserPrincipalName})");

    FolderPermissions.Grant(
        Environment.GetEnvironmentVariable("SPO_SITE")!,
        Environment.GetEnvironmentVariable("SPO_TOKEN")!,
        "SPGDemo Folder Access", folderName, picked.UserPrincipalName!, roleName);
}

Checkpoint: The project builds with no errors, and FolderPermissions.cs is shared by the console and WinForms projects.

Test the Tool

With the three environment variables set, I ran these commands from the project folder on 10/24/2026:

dotnet run -- list
dotnet run -- search M
dotnet run -- count
dotnet run -- grant Miller "Client A - 2026 Contracts" Read

The list, search and count runs match the screenshots in Steps 4 and 5. The grant run is the one that changes SharePoint:

C# console output granting Miller Read access to a SharePoint folder after looking up the user in Microsoft Graph
  • Graph returned shows the user Graph found and the UPN passed to SharePoint.
  • The folder now has unique permissions: the copied site groups plus a new Miller – Read entry.
  • To confirm from outside the app, run a folder permissions report with PnP PowerShell.

Make It Production-Ready

  • Least privilege: request User.ReadBasic.All unless you truly need more profile fields.
  • Bigger pages: set $top to 999 for full loads; headers like ConsistencyLevel aren’t re-sent on later pages unless you add them in the iterator’s request configurator.
  • Groups over people: granting folder access to a SharePoint group scales better; see our SharePoint permissions best practices.
  • No secrets in desktop apps: use interactive or device code sign-in, never a client secret in the .exe.

Troubleshooting

This one I triggered on purpose by dropping the header from a $search request:

Microsoft Graph 400 Request_UnsupportedQuery error when $search is sent without the ConsistencyLevel eventual header
Graph error 400: Request_UnsupportedQuery
Request with $search query parameter only works through MSGraph with a special request header: 'ConsistencyLevel: eventual'
ProblemFix
400 Request_UnsupportedQuery with $searchAdd ConsistencyLevel: eventual and $count=true.
403 Authorization_RequestDeniedThe token lacks User.ReadBasic.All or User.Read.All; add it and get consent.
401 Unauthorized from CSOMYou sent the Graph token to SharePoint. I hit this until I used Get-PnPAccessToken -ResourceTypeName SharePoint.
graph.windows.net calls failAzure AD Graph is retired; move the call to graph.microsoft.com.

If Connect-PnPOnline itself fails before you even get a token, check the Connect-PnPOnline not recognized fix.

Where to Take It Next

More SharePoint developer guides you might want next:

Frequently Asked Questions

How do I get all users from Entra ID with Microsoft Graph in C#?

Call graphClient.Users.GetAsync() with Select set to displayName and userPrincipalName, then pass the first page to PageIterator and call IterateAsync() so every @odata.nextLink page is read. See Microsoft Learn: List users.

Does Azure AD Graph (graph.windows.net) still work?

No. Extended access ended on August 31, 2025, and Azure AD Graph is fully retired. Microsoft Learn: Migrate from Azure AD Graph has the timeline.

What permission do I need to read user names?

User.ReadBasic.All (delegated) is the least privileged. It covers displayName and userPrincipalName. Use User.Read.All for full profiles or for app-only code, which needs admin consent.

Why does $search need the ConsistencyLevel header?

$search on directory objects is an advanced query. Without ConsistencyLevel: eventual and $count=true, Graph returns 400 Request_UnsupportedQuery.