Azure Functions for Microsoft 365 Developers: Build Serverless APIs
The practical guide to building Azure Functions for Microsoft 365. Patterns, security, Microsoft Graph, deploy with Bicep and GitHub Actions.
If you build on Microsoft 365 (M365), you’ve probably hit the moment where a client-side solution stops being enough. You need a secret that can’t ship in a browser. You need to receive a Microsoft Graph webhook. You need to do work a user isn’t waiting on. Something has to run server-side… and for most M365 scenarios, that something should be an Azure Function.
Here’s the thing: most of the Azure Functions content you’ll find is written for generic web or data workloads. It doesn’t explain how an Azure Function fits into a SharePoint Framework (SPFx) web part, a Microsoft Teams tab, a Microsoft Graph subscription, or a Microsoft 365 Copilot declarative agent (DA). That’s exactly the gap this guide fills.
This isn’t a marketing overview. It’s a working blueprint covering the four patterns you’ll actually use, how to secure your API with Microsoft Entra ID, how to talk to Microsoft Graph, how to deploy with Bicep, and how to ship with GitHub Actions. Each section links to a full walkthrough if you want to go deeper.
What “Azure Functions for Microsoft 365” actually means
Azure Functions is a serverless compute service. You write a function, Azure decides when and how to run it, and you pay for the time it actually executes. No VMs to patch, no web server to babysit.
For M365 developers, the specific shape that matters is this: an Azure Function App hosts one or more HTTP endpoints (or event-triggered functions) that your M365 surface (an SPFx web part, a Teams tab, a Copilot agent, a Graph subscription) calls into. The Function App is the server-side of your otherwise client-side world.
Three things make this pairing important:
- Secrets stay on the server. Certificates, client secrets, API keys for third-party services, these never belong in browser-delivered code.
- Elevated permissions are possible. A Function can hold an application identity with app-only Microsoft Graph permissions that a user shouldn’t have.
- Background work is safe. Long-running jobs, queue processing, scheduled tasks, webhook receivers… these belong on a server, not in a user’s browser session.
When I say “Azure Functions for M365,” I mean the sliver of the Functions surface area that an M365 developer actually uses. You won’t need durable orchestrations for your first ten projects. You’ll need HTTP triggers, a handful of bindings, and a clean security model.
When you DO need an Azure Function (and when you don’t)
Be honest before you build.
If you can solve the problem with the SharePoint REST API, the Microsoft Graph JavaScript client inside SPFx with an AadHttpClient, or a Power Automate flow, you probably should. Every Function App you create is something you now own: logs to watch, secrets to rotate, dependencies to patch, a deployment pipeline to maintain. Don’t take on that weight for a UI detail.
You do need an Azure Function when any of these are true:
- You need to call an API that requires a client secret or certificate you can’t expose to the browser.
- You need to use app-only Microsoft Graph permissions (the user running the UI doesn’t have the scope, but your service does).
- You need to receive Microsoft Graph change notifications (webhooks). Graph posts to a public HTTPS endpoint you own, and your SPFx web part isn’t one.
- You need to do long-running or scheduled work that shouldn’t block a user.
- You need a shared backend called by more than one client (a Teams tab, an SPFx web part, and a Copilot agent all hitting the same API).
Side note: if you’re building a Teams tab with the Microsoft 365 Agents Toolkit (formerly Teams Toolkit, because of course Microsoft renamed it), the scaffold will offer you a Function by default. Don’t assume you need it. Check when you don’t actually need the Function before you keep the generated code.
Four patterns every Microsoft 365 developer needs
Nearly every M365-integrated Function App I’ve built or reviewed falls into one of four patterns. Learn these, and you’ll recognize 90% of the scenarios you run into.
Pattern 1: SPFx calls a Function. A web part (or extension) makes an authenticated request to your API. The Function validates the token, optionally exchanges it for a Microsoft Graph token using on-behalf-of (OBO), does its work, and returns JSON. This is the workhorse pattern.
Pattern 2: Teams tab backend. A Teams tab (static or configurable) calls an authenticated API hosted in your Function App. Same security model as Pattern 1. The difference is the identity provider flow inside Teams and the fact that you’re often calling the same API from both web and desktop Teams clients.
Pattern 3: Microsoft Graph webhook receiver. You subscribe to change notifications on a Graph resource (mail, files, Teams messages, calendar events). Graph calls your Function’s public HTTPS endpoint when something changes. Your Function must respond to the validation handshake in under 10 seconds and then handle the notification payload.
Pattern 4: Copilot agent or DA action. A declarative agent in Microsoft 365 Copilot calls your Function as an action (via an OpenAPI spec). The Function enforces auth, validates the caller, and returns structured data the agent reasons over.
| Pattern | Caller | The Function’s job |
|---|---|---|
| 1 · SPFx calls a Function | SPFx web part or extension | Validate token, optional OBO → Graph, return JSON |
| 2 · Teams tab backend | Teams tab via SSO | Same as pattern 1, with the Teams identity flow |
| 3 · Graph webhook receiver | Microsoft Graph | Validation handshake, clientState check, process notification |
| 4 · Copilot agent action | Declarative agent via OpenAPI | Validate caller, return structured data |
Every pattern shares the same spine: an HTTP trigger, token validation, and (usually) a call to Microsoft Graph. Master those three and the patterns collapse into variations.
Your first HTTP trigger (Node.js v4)
Let’s get the simplest version of the workhorse running. I default to the Node.js v4 programming model. It’s cleaner than v3 (no function.json files, everything lives in code) and it’s the current recommendation for new projects. If you’re a C# shop, the equivalent is the .NET isolated worker model; the patterns map one-to-one and Microsoft’s docs cover the syntax.
Install the Core Tools and create a new project, then add an HTTP function. The minimal shape looks like this:
1import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
2
3export async function hello(req: HttpRequest, ctx: InvocationContext): Promise<HttpResponseInit> {
4 const name = req.query.get('name') ?? (await req.text()) ?? 'world';
5 ctx.log(`request for ${req.url}`);
6 return { status: 200, jsonBody: { message: `Hello, ${name}` } };
7}
8
9app.http('hello', { methods: ['GET', 'POST'], authLevel: 'anonymous', handler: hello });You’ll also want a host.json pointing at a current extension bundle and a local.settings.json with FUNCTIONS_WORKER_RUNTIME=node and a storage placeholder so the local runtime starts clean.
1// host.json
2{
3 "version": "2.0",
4 "extensionBundle": {
5 "id": "Microsoft.Azure.Functions.ExtensionBundle",
6 "version": "[4.*, 5.0.0)"
7 }
8}1// local.settings.json — never commit; add to .gitignore
2{
3 "IsEncrypted": false,
4 "Values": {
5 "FUNCTIONS_WORKER_RUNTIME": "node",
6 "AzureWebJobsStorage": "UseDevelopmentStorage=true"
7 }
8}Run func start locally, hit the endpoint in a browser or with curl, and confirm you see your response. That’s it. That’s the server-side foundation every pattern in this guide builds on.
A couple of things that bite people the first time:
- Authorization level. The HTTP trigger’s
authLevelcontrols the Functions-host-level key check. For anything in production that’s being called by a user, you don’t wantanonymousrelying on Function keys alone; you want real Entra ID token validation (next section). Useanonymousat the host level and enforce auth in code (or in Easy Auth) so your security isn’t a shared secret in a URL. - Node versions. The Functions runtime supports specific Node LTS versions. Match your local Node to what you’ll run in Azure, or you’ll get “it works on my machine” bugs that are miserable to debug.
Pattern deep dives
Pattern 1: SPFx web part calls an Azure Function
Your web part uses AadHttpClient to make an authenticated request. SPFx handles the token acquisition for you, as long as you’ve requested the correct API permissions in your solution manifest and an admin has approved them in the SharePoint API permissions page.
1import { AadHttpClient } from '@microsoft/sp-http';
2
3const client = await this.context.aadHttpClientFactory.getClient('api://contoso-func-api');
4const res = await client.get(
5 'https://contoso-func.azurewebsites.net/api/orders',
6 AadHttpClient.configurations.v1
7);
8const orders = await res.json();On the Function side, you validate the incoming access token (see the security section below) and optionally exchange it for a Microsoft Graph token using on-behalf-of if your Function needs to call Graph as the user who called you.
Pattern 2: Teams tab backend
The Teams tab SSO flow gets the token into your JavaScript. From there, it’s the same call-my-API pattern as SPFx. The gotcha: make sure your app registration’s API permissions, redirect URIs, and the webApplicationInfo node in your Teams manifest all match. One mismatched GUID and you’ll spend an hour wondering why consent isn’t sticking.
Pattern 3: Microsoft Graph webhook receiver
This one has the highest blast radius when you get it wrong. Three things matter:
The validation handshake. When you create a subscription, Graph immediately calls your endpoint with a
validationTokenquery parameter. You have 10 seconds to respond with HTTP 200, content-typetext/plain, and the raw token in the body. Miss the window and the subscription creation fails.1const validationToken = req.query.get('validationToken'); 2if (validationToken) { 3 // Respond within 10s with the raw token as text/plain. 4 return { status: 200, headers: { 'Content-Type': 'text/plain' }, body: validationToken }; 5}The 10-second rule is absoluteGraph does not retry the validation handshake. If your cold-started Function takes 12 seconds to return the token, the subscription creation fails and you start over. Keep the validation handler small and at the top of the file.
The
clientStatecheck. When you register the subscription, you pass aclientStatestring. Graph echoes it back on every notification. Check it on every incoming notification and reject any that don’t match. This is your “is this really Graph calling me?” signal.1const { value } = (await req.json()) as { value: Array<{ clientState: string }> }; 2for (const change of value) { 3 if (change.clientState !== process.env.GRAPH_CLIENT_STATE) { 4 ctx.warn('clientState mismatch — dropping notification'); 5 continue; 6 } 7 await enqueue(change); // fan out real work off the request thread 8} 9return { status: 202 };Subscription renewal. Subscriptions expire (max lifetime varies by resource). You need a timer-triggered Function that renews them before expiry or a subscription that’s part of your job.
For the full walkthrough including the pairing with delta query for efficient change tracking, see the deep dive on Graph webhooks and delta query.
Pattern 4: Copilot declarative agent action
A declarative agent in Microsoft 365 Copilot can call an external API as an action. You supply an OpenAPI spec, Copilot formulates the call, your Function authenticates the request, does the work, and returns a structured response that Copilot reasons over.
The security model is the same one you’ve seen three times now: validate the token in your Function before you do anything else. The content of the response matters more here than usual, because Copilot is going to paraphrase it into a user-facing answer. Return clean, structured data with predictable shapes, not ad-hoc text.
Securing the API with Microsoft Entra ID
This is the section most articles skip, so here’s the condensed, correct version.
You have two real choices for enforcing authentication on an Azure Function that’s called by M365 surfaces:
- App Service Authentication (Easy Auth) turned on for your Function App. Azure enforces the token check before your code runs. You get identity claims via headers. It’s genuinely easy. It’s also less flexible when you need to handle multiple audiences or customize error responses.
- In-code token validation using a library like
jose. You load the tenant’s JSON Web Key Set (JWKS), verify the JWT’s signature, and check the issuer and audience claims yourself. More code, but you own the flow and you can inspect everything.
For most M365 scenarios, I reach for in-code validation because I want to see what’s happening and I often need to validate against multiple audiences (SPFx, Teams, and a custom client all hitting the same API).
1import { createRemoteJWKSet, jwtVerify } from 'jose';
2
3const tenantId = process.env.TENANT_ID!;
4const audience = process.env.API_CLIENT_ID!; // your API's app registration
5const JWKS = createRemoteJWKSet(
6 new URL(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`)
7);
8
9export async function validateToken(authHeader?: string) {
10 const token = authHeader?.replace(/^Bearer\s+/i, '');
11 if (!token) throw new Error('missing bearer token');
12
13 // Validate issuer AND audience, not just the signature.
14 const { payload } = await jwtVerify(token, JWKS, {
15 issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`,
16 audience,
17 });
18 return payload;
19}A few rules I don’t break:
- Validate issuer AND audience. A signature check alone is not enough. A token signed by Microsoft Entra but issued for a different resource is not for you.
- Use app roles for app-only permissions, delegated scopes for on-behalf-of-a-user calls. Don’t mix them. If you’re unsure which model you need, read the post on custom app roles and scopes.
- Never log the raw token. Logs get shipped to places you don’t control. Redact before you log.
For step-by-step code on validating Entra ID tokens in a Function App that’s called from SPFx, see the full token validation walkthrough. If this is the part you’re new to, the free Microsoft 365 developer learning path walks through Entra ID fundamentals too.
Connecting to Microsoft Graph
Once your Function has a validated user token (Pattern 1, 2, or 4) or an app identity (any pattern that needs app-only access), calling Microsoft Graph is straightforward.
Two decisions shape your code:
App-only or on-behalf-of? If you need to read data the calling user doesn’t have access to (tenant-wide reporting, cross-user operations), you want app-only permissions on an app registration using a certificate or managed identity. If you need to act as the user (post a message as them, read their mail), you use the OBO flow to exchange the incoming token for a Graph token.
1import { ConfidentialClientApplication } from '@azure/msal-node';
2
3const cca = new ConfidentialClientApplication({
4 auth: {
5 clientId: process.env.API_CLIENT_ID!,
6 authority: `https://login.microsoftonline.com/${process.env.TENANT_ID}`,
7 clientCertificate: { thumbprint, privateKey }, // prefer a cert or managed identity over a secret
8 },
9});
10
11const result = await cca.acquireTokenOnBehalfOf({
12 oboAssertion: incomingUserToken, // the token your API just validated
13 scopes: ['https://graph.microsoft.com/User.Read'],
14});
15// result.accessToken → call Microsoft Graph as the user
Certificate, secret, or managed identity? For production, use a managed identity on the Function App and grant it Microsoft Graph permissions directly. You stop managing secrets, and rotation becomes Azure’s problem. If you must use a client credential, prefer certificates over secrets and store them in Key Vault (referenced by app setting, not pasted into app settings).
Deploying with Bicep (infrastructure as code)
Don’t click-ops a Function App that’s part of a real solution. Define it in Bicep, commit it, and let your pipeline deploy it. You’ll thank yourself the first time you need a second environment.
1resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
2 name: '${prefix}-flex'
3 location: location
4 sku: { tier: 'FlexConsumption', name: 'FC1' }
5 kind: 'functionapp'
6 properties: { reserved: true }
7}
8
9resource func 'Microsoft.Web/sites@2023-12-01' = {
10 name: '${prefix}-func'
11 location: location
12 kind: 'functionapp,linux'
13 identity: { type: 'SystemAssigned' } // managed identity → no secrets to rotate
14 properties: {
15 serverFarmId: plan.id
16 functionAppConfig: {
17 runtime: { name: 'node', version: '20' }
18 scaleAndConcurrency: { instanceMemoryMB: 2048, maximumInstanceCount: 40 }
19 }
20 }
21}Use Flex Consumption as your default hosting plan. It’s the 2026 recommendation for new M365-adjacent workloads: better cold-start behavior than classic Consumption, per-instance concurrency controls, and a cleaner scaling model. Consumption still works and is still cheapest for very low traffic, but Flex Consumption is where Microsoft is investing.
If you need VNet integration, private endpoints to on-premises resources, or specific regional compliance features Flex Consumption doesn’t yet support, Premium plan is the answer. Brief mention only: don’t default there. Check the current Flex Consumption feature matrix before assuming you need Premium.
For the full Bicep template covering the storage account, app service plan (or Flex Consumption app), Application Insights, and app settings wired up correctly, see the full step-by-step Bicep walkthrough.
CI/CD with GitHub Actions
Every Function App I ship has a two-workflow pattern: one workflow deploys infrastructure (Bicep) and one deploys code. They can be triggered separately or chained on the main branch.
The key thing to get right is authentication. Use OIDC federated credentials, not a stored service principal secret. Federated credentials let the workflow mint a short-lived token from Entra ID using the GitHub Actions identity, so there’s nothing in your repo secrets that can be stolen.
1permissions:
2 id-token: write # required for federated OIDC
3 contents: read
4
5jobs:
6 deploy:
7 runs-on: ubuntu-latest
8 steps:
9 - uses: actions/checkout@v4
10 - uses: azure/login@v2
11 with:
12 client-id: ${{ secrets.AZURE_CLIENT_ID }}
13 tenant-id: ${{ secrets.AZURE_TENANT_ID }}
14 subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
15 - uses: azure/functions-action@v1
16 with:
17 app-name: contoso-func
18 package: .Beyond that: run tests before deploy, fail the build on lint errors, and use environment protection rules so production deploys require an approval. For the end-to-end workflow including the federated credential setup on the app registration, see the complete GitHub Actions deployment guide.
Troubleshooting (what actually breaks)
The failures you’ll hit, in rough order of frequency:
- Local runtime won’t start. Usually a Node version mismatch between your local install and the Functions runtime. If you’re on macOS and juggling Node versions, read the macOS Node version troubleshooting guide.
- 401 Unauthorized on every call. Check your
audiencevalue in token validation. The most common mistake is validating against the wrong app registration’s client ID. - 403 Forbidden from Microsoft Graph. Consent wasn’t granted for the permission you’re asking for. Check the enterprise application’s consented permissions in Entra, not just the app registration’s declared permissions.
- Webhook validation fails. Your Function cold-started and the 10-second window expired. Warm the app before creating the subscription, or move to Flex Consumption which mitigates cold-start.
AadHttpClientfrom SPFx returns an empty response. The webpart-scoped API permission request wasn’t approved by a tenant admin in SharePoint’s API access page.- Token works locally, fails in Azure. Your local settings and Azure app settings drifted. Use
func azure functionapp fetch-app-settingsto pull the real settings into a local file (outside source control) and compare.
Where to go from here
You’ve got the map. The next step is picking one pattern and building the smallest possible version of it end-to-end: a Function, a token check, a Graph call, deployed via Bicep, shipped via GitHub Actions. Do that once and the rest of the patterns are variations you already understand.
If Entra ID and the Microsoft 365 developer stack are still new ground, the free Microsoft 365 developer learning path covers the fundamentals in a sequence that won’t waste your time.
Now I’m curious: what’s your Function pattern? Are you running all four in production, or have you found a fifth I haven’t seen? Reply to the newsletter or hit me on the usual channels. I read every response.