Your web part needs the signed-in user’s profile, their photo or data from another SharePoint site, and SharePoint REST can’t give you all of it. Microsoft Graph can, and SPFx already handles the sign-in for you.
In this tutorial, we’ll use the Microsoft Graph API in SPFx the way our team does on client projects: request the permissions, get MSGraphClientV3, call /me, /me/photo/$value and a site’s lists, test it in the SharePoint workbench, and get the permissions approved.
Quick answer: Add the scopes to webApiPermissionRequests in config/package-solution.json, get the client with this.context.msGraphClientFactory.getClient('3') and call client.api('/me').get(). After the .sppkg is uploaded, a Global Administrator approves the scopes on the API access page of the SharePoint admin center.
import { MSGraphClientV3 } from '@microsoft/sp-http';
const client: MSGraphClientV3 = await this.context.msGraphClientFactory.getClient('3');
const me = await client.api('/me').select('displayName,mail').get();
| Tested with | Version |
|---|---|
| SharePoint Framework (SPFx) | 1.23.2 with the Heft toolchain |
| Node.js | 22 LTS |
| React / Fluent UI | 17.0.1 / @fluentui/react 8 |
| Graph clients | MSGraphClientV3 (Graph JavaScript client v3), @pnp/graph 4.22.0 |
Here’s the web part we’ll build, running in the SPGuides workbench:

What You Need Before You Start
- A working SPFx 1.23 setup: Node.js 22, Yeoman, the SharePoint generator and Heft. Our guide to setting up SPFx with the Heft toolchain covers it.
- A SharePoint Online site to test on. We use
https://spguides.sharepoint.com/sites/SPGuides. - A Global Administrator who can approve Microsoft Graph permissions for the tenant (you don’t need that role to build and test).
- Visual Studio Code, so you can edit the files and run every command in its terminal.
Two terms come up a lot. A scope (permission) such as User.Read says what the web part may read. Delegated means SPFx calls Graph as the signed-in user, so nobody sees more than they already have access to.
Step 1: Create the SPFx Web Part Project
Open a terminal in VS Code, create a folder and run the SharePoint generator. The flags answer the prompts in one go: a React web part called GraphProfile in a solution called spg-graph-api:
md spg-graph-api
cd spg-graph-api
yo @microsoft/sharepoint --solution-name spg-graph-api --component-type webpart --component-name GraphProfile --framework react --environment spo
The generator installs the packages, which took a few minutes on our machine. Then add PnPjs for Graph, which we use as an alternative later, and the Microsoft Graph TypeScript types:
npm install @pnp/graph@4.22.0 --save-exact
npm install @microsoft/microsoft-graph-types --save-dev
You don’t install anything for MSGraphClientV3 itself. It ships in @microsoft/sp-http with every SPFx project. Older posts and projects used gulp serve; since SPFx 1.22 it’s heft start, wrapped in npm run start. Have an older gulp project? Migrate it to the Heft toolchain first.
We add a models and a services folder under src/webparts/graphProfile. Here’s the finished project in VS Code:

Step 2: Request the Microsoft Graph Permissions
SPFx doesn’t get access to Microsoft Graph automatically. Open config/package-solution.json and add webApiPermissionRequests inside solution, one entry per scope:
"webApiPermissionRequests": [
{
"resource": "Microsoft Graph",
"scope": "User.Read"
},
{
"resource": "Microsoft Graph",
"scope": "Sites.Read.All"
}
]

resource must be the display name Microsoft Graph. Microsoft notes that an object ID here causes an error when the admin approves the request. The rest of the file stays as the generator wrote it, apart from the solution name spg-graph-api.
Ask for the smallest scope that works. Approved permissions apply to every SPFx solution and script in the tenant, not only yours, so a broad scope such as Sites.ReadWrite.All deserves the questions your admin will ask. If a component only needs the current site, getting the current site information with MS Graph shows a lighter route.
Step 3: Get MSGraphClientV3 in onInit
MSGraphClientV3 came with SPFx 1.15 and wraps the Microsoft Graph JavaScript client library v3. You get it from msGraphClientFactory on the web part context, with '3' as the version. Replace src/webparts/graphProfile/GraphProfileWebPart.ts:
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
type IPropertyPaneConfiguration,
PropertyPaneChoiceGroup,
PropertyPaneSlider
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { MSGraphClientV3 } from '@microsoft/sp-http';
import { graphfi, SPFx } from '@pnp/graph';
import * as strings from 'GraphProfileWebPartStrings';
import GraphProfile from './components/GraphProfile';
import { IGraphProfileProps } from './components/IGraphProfileProps';
import { GraphService } from './services/GraphService';
import { ProfileSource } from './models/IGraphData';
export interface IGraphProfileWebPartProps {
profileSource: ProfileSource;
listCount: number;
}
export default class GraphProfileWebPart extends BaseClientSideWebPart<IGraphProfileWebPartProps> {
private _service!: GraphService; // set in onInit, before the first render
protected async onInit(): Promise<void> {
await super.onInit();
// One Graph client for the life of the web part (version '3' = MSGraphClientV3)
const client: MSGraphClientV3 = await this.context.msGraphClientFactory.getClient('3');
// PnPjs alternative, same SPFx sign-in under the hood
const graph = graphfi().using(SPFx(this.context));
// Graph site id = "{hostname},{site collection id},{web id}" - built from pageContext, no extra call
const hostname = new URL(this.context.pageContext.site.absoluteUrl).hostname;
const siteId = `${hostname},${this.context.pageContext.site.id.toString()},${this.context.pageContext.web.id.toString()}`;
this._service = new GraphService(client, graph, siteId);
}
public render(): void {
const element: React.ReactElement<IGraphProfileProps> = React.createElement(GraphProfile, {
service: this._service,
profileSource: this.properties.profileSource || 'msgraph',
listCount: this.properties.listCount || 8
});
ReactDom.render(element, this.domElement);
}
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
if (!currentTheme) {
return;
}
const { semanticColors } = currentTheme;
if (semanticColors) {
this.domElement.style.setProperty('--bodyText', semanticColors.bodyText || null);
}
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: { description: strings.PropertyPaneDescription },
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneChoiceGroup('profileSource', {
label: strings.ProfileSourceLabel,
options: [
{ key: 'msgraph', text: 'MSGraphClientV3' },
{ key: 'pnp', text: 'PnPjs (@pnp/graph)' }
]
}),
PropertyPaneSlider('listCount', {
label: strings.ListCountLabel,
min: 3,
max: 20,
step: 1
})
]
}
]
}
]
};
}
}

getClient('3')runs once inonInit, so React doesn’t ask for a new client on every render. It returns a promise, so weawaitit.graphfi().using(SPFx(this.context))sets up PnPjs on the same SPFx sign-in. We’ll compare both clients in Step 7.- The Graph site ID has the form
{hostname},{site collection id},{web id}.pageContextalready holds all three, so we build it without an extra call. - The property pane has two settings: which client loads the profile (a ChoiceGroup property pane field) and how many lists to show (a slider).
Update the labels in src/webparts/graphProfile/loc/mystrings.d.ts:
declare interface IGraphProfileWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
ProfileSourceLabel: string;
ListCountLabel: string;
}
declare module 'GraphProfileWebPartStrings' {
const strings: IGraphProfileWebPartStrings;
export = strings;
}
And the English strings in src/webparts/graphProfile/loc/en-us.js:
define([], function() {
return {
"PropertyPaneDescription": "Microsoft Graph settings",
"BasicGroupName": "Data",
"ProfileSourceLabel": "Load the profile with",
"ListCountLabel": "Number of lists to show"
}
});
Step 4: Call /me and /me/photo/$value
First, the shapes the web part shows. Create src/webparts/graphProfile/models/IGraphData.ts:
// Shapes the web part shows. Graph returns more fields; we $select only these.
export interface IUserProfile {
displayName: string;
jobTitle?: string;
mail?: string;
officeLocation?: string;
userPrincipalName: string;
}
export interface ISiteInfo {
displayName: string;
webUrl: string;
}
export interface ISiteList {
id: string;
displayName: string;
template: string;
lastModified: string;
webUrl: string;
}
export type ProfileSource = 'msgraph' | 'pnp';
All Graph calls live in one service class. Create src/webparts/graphProfile/services/GraphService.ts:
import { MSGraphClientV3 } from '@microsoft/sp-http';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import { GraphFI } from '@pnp/graph';
import '@pnp/graph/users';
import { IUserProfile, ISiteInfo, ISiteList, ProfileSource } from '../models/IGraphData';
const PROFILE_FIELDS = ['displayName', 'jobTitle', 'mail', 'officeLocation', 'userPrincipalName'];
export class GraphService {
constructor(
private _client: MSGraphClientV3, // from this.context.msGraphClientFactory.getClient('3')
private _graph: GraphFI, // PnPjs: graphfi().using(SPFx(this.context))
private _siteId: string // "{hostname},{site collection id},{web id}"
) { }
// GET /me (User.Read)
public async getMe(source: ProfileSource): Promise<IUserProfile> {
if (source === 'pnp') {
const me = await this._graph.me.select(...PROFILE_FIELDS)();
return this._toProfile(me as MicrosoftGraph.User);
}
const me: MicrosoftGraph.User = await this._client
.api('/me')
.select(PROFILE_FIELDS.join(','))
.get();
return this._toProfile(me);
}
// GET /me/photo/$value (User.Read) -> object URL for <img>, or undefined when the user has no photo (404)
public async getMyPhotoUrl(): Promise<string | undefined> {
try {
const photo: Blob = await this._client.api('/me/photo/$value').get();
return URL.createObjectURL(photo);
} catch (error) {
if ((error as { statusCode?: number }).statusCode === 404) {
return undefined;
}
throw error;
}
}
// GET /sites/{site-id} (Sites.Read.All)
public async getSite(): Promise<ISiteInfo> {
const site: MicrosoftGraph.Site = await this._client
.api(`/sites/${this._siteId}`)
.select('displayName,webUrl')
.get();
return { displayName: site.displayName || '', webUrl: site.webUrl || '' };
}
// GET /sites/{site-id}/lists (Sites.Read.All) - visible lists and libraries, newest change first
public async getLists(top: number): Promise<ISiteList[]> {
const response: { value: MicrosoftGraph.List[] } = await this._client
.api(`/sites/${this._siteId}/lists`)
.select('id,displayName,list,lastModifiedDateTime,webUrl')
.get();
return response.value
.filter(l => !l.list?.hidden)
.sort((a, b) => (b.lastModifiedDateTime || '').localeCompare(a.lastModifiedDateTime || ''))
.slice(0, top)
.map(l => ({
id: l.id || '',
displayName: l.displayName || '',
template: l.list?.template || '',
lastModified: l.lastModifiedDateTime || '',
webUrl: l.webUrl || ''
}));
}
private _toProfile(me: MicrosoftGraph.User): IUserProfile {
return {
displayName: me.displayName || '',
jobTitle: me.jobTitle || undefined,
mail: me.mail || undefined,
officeLocation: me.officeLocation || undefined,
userPrincipalName: me.userPrincipalName || ''
};
}
}
// Turns a Graph error (MSGraphClientV3 GraphError or PnPjs HttpRequestError) into a readable line.
export function graphErrorText(error: unknown): string {
const e = error as { statusCode?: number; status?: number; code?: string; message?: string };
const status = e.statusCode || e.status;
return [status ? `HTTP ${status}` : '', e.code || '', e.message || String(error)].filter(Boolean).join(' - ');
}
Here’s what the two profile calls do:
If you need the same directory data outside SPFx, for example in a desktop tool, see how to get user names from Entra ID with Microsoft Graph in C#.
client.api('/me').select(...).get()reads the signed-in user.selectasks only for the five fields we show, which keeps the response small. It needsUser.Read./me/photo/$valuereturns the photo itself, not JSON. The client hands it back as a Blob, andURL.createObjectURLturns it into a URL an<img>can use. In our test, the 2026 profile photo loaded straight into the card.- If the user has no photo, Graph answers 404. We catch that one status and show initials instead of an error.
Which permission does /me/photo/$value need? Microsoft lists ProfilePhoto.Read.All as the least privileged one, and User.Read also works. Since /me needs User.Read anyway, we request only that. For a profile card without Graph at all, displaying the current user name in SPFx uses pageContext.
Step 5: Read SharePoint Sites and Lists With Graph
Graph reaches SharePoint through /sites. The service’s getSite and getLists methods send these two requests:
// "{hostname},{site collection id},{web id}", for example:
// spguides.sharepoint.com,8f1c2c3e-....,d27a9a4b-....
GET https://graph.microsoft.com/v1.0/sites/{site-id}?$select=displayName,webUrl
GET https://graph.microsoft.com/v1.0/sites/{site-id}/lists?$select=id,displayName,list,lastModifiedDateTime,webUrl
Both need Sites.Read.All. The lists call skips lists that carry the system facet by default, and we also filter out hidden lists in code, then sort by last change. To reach another site, address it by path:
// Another site, by its path (no ids needed)
const hr = await client.api('/sites/spguides.sharepoint.com:/sites/HR').select('id,displayName').get();
Graph or SharePoint REST? For lists on the current site, SharePoint REST and PnPjs @pnp/sp need no admin approval, as in displaying SharePoint list items in SPFx. Graph wins when one web part also needs users, mail, Teams or files from several sites.
Step 6: Show the Results in React
The component gets the service through its props. Replace src/webparts/graphProfile/components/IGraphProfileProps.ts:
import { GraphService } from '../services/GraphService';
import { ProfileSource } from '../models/IGraphData';
export interface IGraphProfileProps {
service: GraphService;
profileSource: ProfileSource;
listCount: number;
}
Then replace src/webparts/graphProfile/components/GraphProfile.tsx:
import * as React from 'react';
import { Persona, PersonaSize } from '@fluentui/react/lib/Persona';
import { Spinner, SpinnerSize } from '@fluentui/react/lib/Spinner';
import { MessageBar, MessageBarType } from '@fluentui/react/lib/MessageBar';
import { DetailsList, DetailsListLayoutMode, IColumn, SelectionMode } from '@fluentui/react/lib/DetailsList';
import styles from './GraphProfile.module.scss';
import { IGraphProfileProps } from './IGraphProfileProps';
import { IUserProfile, ISiteInfo, ISiteList } from '../models/IGraphData';
import { graphErrorText } from '../services/GraphService';
const columns: IColumn[] = [
{ key: 'name', name: 'List or library', fieldName: 'displayName', minWidth: 160, maxWidth: 260, isResizable: true },
{ key: 'template', name: 'Template', fieldName: 'template', minWidth: 110, maxWidth: 160 },
{
key: 'modified', name: 'Last modified', fieldName: 'lastModified', minWidth: 120,
onRender: (item: ISiteList) => new Date(item.lastModified).toLocaleDateString('en-US')
}
];
const GraphProfile: React.FC<IGraphProfileProps> = (props) => {
const { service, profileSource, listCount } = props;
const [me, setMe] = React.useState<IUserProfile | undefined>();
const [photoUrl, setPhotoUrl] = React.useState<string | undefined>();
const [site, setSite] = React.useState<ISiteInfo | undefined>();
const [lists, setLists] = React.useState<ISiteList[]>([]);
const [loading, setLoading] = React.useState<boolean>(true);
const [profileError, setProfileError] = React.useState<string | undefined>();
const [listError, setListError] = React.useState<string | undefined>();
React.useEffect(() => {
let cancelled = false;
setLoading(true);
// Profile + photo need only User.Read. Site + lists need Sites.Read.All.
// Separate try/catch blocks, so a missing Sites.Read.All grant doesn't hide the profile.
const loadProfile = async (): Promise<void> => {
try {
const [profile, photo] = await Promise.all([service.getMe(profileSource), service.getMyPhotoUrl()]);
if (!cancelled) { setMe(profile); setPhotoUrl(photo); setProfileError(undefined); }
} catch (error) {
if (!cancelled) { setProfileError(graphErrorText(error)); }
}
};
const loadLists = async (): Promise<void> => {
try {
const [siteInfo, siteLists] = await Promise.all([service.getSite(), service.getLists(listCount)]);
if (!cancelled) { setSite(siteInfo); setLists(siteLists); setListError(undefined); }
} catch (error) {
if (!cancelled) { setListError(graphErrorText(error)); }
}
};
Promise.all([loadProfile(), loadLists()])
.then(() => { if (!cancelled) { setLoading(false); } })
.catch(() => { if (!cancelled) { setLoading(false); } });
return () => { cancelled = true; };
}, [service, profileSource, listCount]);
// Release the photo's object URL when it changes or the web part unloads.
React.useEffect(() => () => { if (photoUrl) { URL.revokeObjectURL(photoUrl); } }, [photoUrl]);
if (loading) {
return <Spinner size={SpinnerSize.large} label="Calling Microsoft Graph..." />;
}
return (
<section className={styles.graphProfile}>
<h2 className={styles.title}>My Microsoft Graph profile</h2>
{profileError && <MessageBar messageBarType={MessageBarType.error}>Profile: {profileError}</MessageBar>}
{me && (
<Persona
imageUrl={photoUrl}
text={me.displayName}
secondaryText={me.jobTitle || me.userPrincipalName}
tertiaryText={me.mail}
optionalText={me.officeLocation}
size={PersonaSize.size72}
/>
)}
<p className={styles.source}>
Profile loaded with {profileSource === 'pnp' ? 'PnPjs graphfi()' : 'MSGraphClientV3'}
{photoUrl ? ', photo from /me/photo/$value' : ', no profile photo (404)'}
</p>
<h3 className={styles.subtitle}>{site ? `Lists on ${site.displayName}` : 'Site lists'}</h3>
{listError && <MessageBar messageBarType={MessageBarType.error}>Lists: {listError}</MessageBar>}
{!listError && (
<DetailsList
items={lists}
columns={columns}
selectionMode={SelectionMode.none}
layoutMode={DetailsListLayoutMode.justified}
compact={true}
/>
)}
</section>
);
};
export default GraphProfile;
- The profile and the lists load in separate
try/catchblocks. IfSites.Read.Allisn’t approved yet, the profile still shows and the lists area shows Graph’s error in a MessageBar. - The Fluent UI
Personashows the photo, name, job title, email and office. Without a photo it falls back to initials. Our Fluent UI in SPFx guide covers the other controls. - The cleanup flag stops state updates after the web part unloads, and the object URL is released when the photo changes.
The styles are short, and the [theme:...] tokens follow the site theme (reading the current theme in SPFx shows the code route). Replace GraphProfile.module.scss:
.graphProfile {
color: "[theme:bodyText, default: #323130]";
color: var(--bodyText);
}
.title {
font-size: 20px;
font-weight: 600;
margin: 0 0 12px;
}
.subtitle {
font-size: 16px;
font-weight: 600;
margin: 20px 0 4px;
}
.source {
font-size: 12px;
color: "[theme:neutralSecondary, default: #605e5c]";
margin: 8px 0 0;
}
Last, give the web part a toolbox name and default values in GraphProfileWebPart.manifest.json. Only preconfiguredEntries changes; configuring preconfiguredEntries explains each field:
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "Advanced" },
"title": { "default": "Graph Profile" },
"description": { "default": "Shows your Microsoft Graph profile, photo and the lists on this site" },
"officeFabricIconFontName": "Contact",
"properties": {
"profileSource": "msgraph",
"listCount": 8
}
}]
Step 7: Test It in the SPGuides Workbench With heft start
Point the local server at your own site’s workbench, so the Graph calls run with a real sign-in. Open config/serve.json and set initialPage:
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://{tenantDomain}/sites/SPGuides/_layouts/workbench.aspx"
}
Heft replaces {tenantDomain} with the SPFX_SERVE_TENANT_DOMAIN environment variable; our SPFx environment variables guide shows how to keep it set. Start the server in the VS Code terminal:
$env:SPFX_SERVE_TENANT_DOMAIN = "spguides.sharepoint.com"
npm run start

heft startopens the workbench fromserve.jsonwith the debug query string. Select Load debug scripts in the Allow debug scripts dialog.- In the workbench, select the + button and search for Graph Profile.
- Add the web part. A spinner shows Calling Microsoft Graph… for a moment.
- The profile card appears with the photo, and below it the 8 most recently changed lists on the site.

In our test, the card showed the name, job title, email and photo, and the list table started with SPGDemo Products, SPGDemo FAQs and SPGDemo Orders, all changed on 10/24/2026 or 10/23/2026.
Now open the property pane (pencil icon) and switch Load the profile with to PnPjs (@pnp/graph):

The same profile comes back, and the line under the card now names PnPjs graphfi() as the client:

One heads-up: Microsoft retires the hosted workbench on 12/01/2026. After that, keep heft start running, open a real page, add the debug query string that heft start prints to its URL and select Allow debug scripts.
Step 8: Package the Solution and Get the Permissions Approved
Stop heft start with Ctrl+C and build the production package:
npm run build
The script runs heft test --clean --production and heft package-solution --production. It took about 70 seconds here and wrote sharepoint/solution/spg-graph-api.sppkg:

Upload the .sppkg to the SharePoint app catalog when you’re ready; Microsoft Learn’s “Deploy your client-side web part” page covers the details. The upload is what creates the permission requests. The app deploys either way, so your code must cope with scopes that aren’t approved yet.
Where does the admin approve them in 2026? Still in the SharePoint admin center, under Advanced > API access. Select the pending request, select Approve, then Approve again in the Approve access panel. For Microsoft Graph, that takes the Global Administrator role.

Our tenant showed no pending requests and 67 approved ones. The Pre-authorized by Microsoft column read Yes for both User.Read and Sites.Read.All, which is why our web part worked without a new approval. Don’t count on that in a client tenant.
If an old bookmark or help link drops you on the admin center home page, open Advanced in the left navigation and select API access. Admins who prefer scripts can use PowerShell, which does the same job:
# SharePoint Online Management Shell, as a SharePoint or Global Administrator
Connect-SPOService -Url "https://spguides-admin.sharepoint.com"
Get-SPOTenantServicePrincipalPermissionRequests # pending requests with their Id
Approve-SPOTenantServicePrincipalPermissionRequest -RequestId "<request id>"
Get-SPOTenantServicePrincipalPermissionGrants # what is already granted
Approved delegated scopes are stored on a Microsoft-managed Entra ID app. Today that’s SharePoint Online Web Client Extensibility, which replaced the older SharePoint Online Client Extensibility Web Application Principal. You can see its permissions under Enterprise applications in the Entra admin center, but Microsoft says to manage them only through API access or these cmdlets.
Microsoft Graph Calls and Permissions Cheat Sheet
These are the calls we reach for most in SPFx, with the least privileged delegated scope to request:
| What you need | Graph call | Scope |
|---|---|---|
| Signed-in user’s profile | /me | User.Read |
| Signed-in user’s photo | /me/photo/$value | User.Read (or ProfilePhoto.Read.All) |
| Other people in the directory | /users | User.ReadBasic.All |
| A SharePoint site and its lists | /sites/{site-id}/lists | Sites.Read.All |
| List items with column values | /sites/{site-id}/lists/{list-id}/items?$expand=fields | Sites.Read.All |
| Recent OneDrive files | /me/drive/recent | Files.Read |
| Teams the user belongs to | /me/joinedTeams | Team.ReadBasic.All |
| Send mail | /me/sendMail | Mail.Send |
Sending mail is a common next step; sending email on behalf of another user with MS Graph walks through the Mail.Send case.
Alternatives: PnPjs @pnp/graph and AadHttpClient
PnPjs v4 wraps the same SPFx Graph client in a fluent, typed syntax. It’s handy once a project already uses @pnp/sp; alias its SPFx import if you use both packages:
import { graphfi, SPFx } from '@pnp/graph';
import '@pnp/graph/users';
const graph = graphfi().using(SPFx(this.context)); // in onInit, after super.onInit()
const me = await graph.me.select('displayName', 'mail', 'jobTitle')();
AadHttpClient is for other APIs secured by Entra ID, such as your own Azure Function. You request its scope in webApiPermissionRequests with the API’s app name as resource, and you create one client per API:
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
// One client per Entra ID-secured API (here: your own Azure Function or web API)
const api: AadHttpClient = await this.context.aadHttpClientFactory.getClient('api://<your-api-app-id>');
const response: HttpClientResponse = await api.get('https://<your-api>.azurewebsites.net/api/orders', AadHttpClient.configurations.v1);
const orders = await response.json();
For a full build of that pattern, see calling an Azure Function from an SPFx web part.
Common Mistakes to Avoid
- Assuming the scope is approved. The app deploys even when the admin rejects the request, and Graph then answers with a 403 error. Catch it and show the message, as our MessageBar does, instead of a blank web part.
- Using the object ID as resource. Use the display name
Microsoft Graph; an object ID fails at approval time. - Calling getClient() on every render. Get the client once in
onInitand pass it down. - Using getClient() without ‘3’. SPFx 1.15+ projects should ask for
'3'; the old MSGraphClient wrapped the v1 library. - Adding MSAL.js yourself. Microsoft doesn’t support using MSAL directly in SPFx; MSGraphClientV3 and AadHttpClient handle the tokens.
- Expecting removal to revoke access. Deleting the app keeps the approved scopes; the admin has to remove them on API access.
Which SPFx Version Introduced MSGraphClientV3?
SPFx 1.15.0. MSGraphClient came in 1.6.0 and wrapped version 1 of the Graph JavaScript library; MSGraphClientV3 wraps version 3. Any project on 1.15 or later, including today’s 1.23.2, uses getClient('3'). If yours is older, updating the SPFx version gets you there first.
Next Steps
The same client works in extensions too, for example a banner built as an SPFx application customizer that greets the user by name.
With the client working, build a fuller people card next: the SPFx user profile web part with MS Graph adds manager and contact details.
Ready-made SPFx web parts are on our Power Platform and SPFx solutions page, and the SPFx tutorials hub lists every build.
We’ve written a few more SPFx builds that pair well with Microsoft Graph:
Frequently Asked Questions
What is msGraphClientFactory in SPFx?
It’s the factory on the web part (or extension) context that hands you a Microsoft Graph client. Call this.context.msGraphClientFactory.getClient(‘3’) to get MSGraphClientV3, which already carries the user’s sign-in.
What permission does /me/photo/$value need?
Delegated User.Read is enough for the signed-in user’s own photo; Microsoft lists ProfilePhoto.Read.All as the least privileged option. The call returns binary image data, or 404 when the user has no photo.
Who approves Graph permissions for an SPFx solution?
A Global Administrator, on the API access page of the SharePoint admin center or with Approve-SPOTenantServicePrincipalPermissionRequest. See Manage access to Microsoft Entra ID-secured APIs.
How do I deploy an SPFx web part that calls Microsoft Graph?
Run npm run build, upload the .sppkg to the tenant app catalog and enable it, then have an admin approve the scopes on API access. See Microsoft Learn: deploy your client-side web part.
Can SPFx call Microsoft Graph with application permissions?
No. MSGraphClientV3 uses delegated permissions only, so every call runs as the signed-in user. For app-only work, call your own API (for example an Azure Function) with AadHttpClient.

Hey! I’m Bijay Kumar, founder of SPGuides.com and a Microsoft Business Applications MVP (Power Automate, Power Apps). I launched this site in 2020 because I truly enjoy working with SharePoint, Power Platform, and SharePoint Framework (SPFx), and wanted to share that passion through step-by-step tutorials, guides, and training videos. My mission is to help you learn these technologies so you can utilize SharePoint, enhance productivity, and potentially build business solutions along the way.