Whimsical forest scene with three gnomes at a stone bridge labelled Identity Transition: on the App Registration side, a gnome holds old keys beside a chest of secrets and tokens and a scroll listing client secret and certificate; in the middle, a gnome hands a glowing OIDC token scroll across the bridge; on the Managed Identity side, a gnome stands among shields reading No Secrets, Auto-Rotated, and Secure Access — symbolizing the move from stored credentials to federated identities.

Goodbye, Client Secrets: Passwordless CI/CD to SharePoint with App Registrations, Managed Identities, and Federated Credentials

How GitHub Actions, Azure DevOps, and GitLab CI/CD authenticate to Microsoft Entra ID and create SharePoint sites without a stored secret — workload identity federation with app registrations and managed identities, plus the PnP PowerShell and CLI for Microsoft 365 contributions that came out of building the demos.

Companion post to my session “From App Registrations to Managed Identity: Identity Patterns for Secure Automation” at the Copilot, Microsoft 365 & Power Platform Community call , Thursday 30 July 2026, 7:00 AM PT / 3:00 PM GMT. Join live via aka.ms/spdev-sig-call-join, or catch the recording afterward on the Microsoft 365 & Power Platform Community YouTube channel. In the session I walk through passwordless (OIDC / workload identity federation) access to SharePoint Online from three CI/CD systems: GitHub Actions, Azure DevOps, and GitLab CI/CD, plus a cross-tenant Logic App variant on top. This post explains the concepts behind those demos, and closes with a couple of open-source contributions that came out of building them.

If you’ve worked with SharePoint automation for more than a few months, you’ve almost certainly stored a client secret or certificate somewhere: a pipeline variable, a key vault, a GitHub secret. It works, until the day it expires unnoticed and breaks a release, or worse, until the day it leaks. This post, and the live demo it accompanies, is about not having that credential exist in the first place, while still letting three very different CI/CD systems (GitHub Actions, Azure DevOps, GitLab CI/CD) authenticate to Microsoft Entra ID and create SharePoint sites on demand.

The mechanism is workload identity federation, expressed in Entra ID as federated credentials attached to an app registration or a managed identity. Let’s build up to it properly.

1. App registrations and managed identities: two ways to give software an identity

Entra ID has people (users) and it has things that act like users: software that needs to authenticate and be authorized, without a human typing a password. There are two flavors of that second category, and mixing them up is the single most common point of confusion when people start with this topic.

An app registration is an identity you create explicitly for an application. Registering an app in Entra ID produces an application object (the template/definition: client ID, redirect URIs, requested permissions), and, once used in your tenant, a service principal (the actual security principal that gets assigned permissions and shows up in sign-in logs). An app registration is portable: it can be multitenant, it can represent a SaaS product used by many organizations, and it can hold credentials too: a client secret, a certificate, or a federated credential. It exists independently of any specific Azure resource.

A managed identity is narrower by design. It’s an identity that Entra ID manages for an Azure resource, and its lifecycle is tied to Azure:

  • A system-assigned managed identity is created and destroyed together with the resource it’s attached to (a Function App, a Logic App, a VM). One resource, one identity, no reuse.
  • A user-assigned managed identity (UAMI) is its own standalone Azure resource that you create once and then attach to one or many other resources. That’s useful when several resources should share the same permissions.

The defining feature of a managed identity is that you never see a secret. Azure’s internal token service hands the resource a token on request (over a local, non-network endpoint), and nothing you could accidentally leak into a log or a repo ever exists. The tradeoff is that a managed identity only really works from inside Azure: something needs to be running on Azure infrastructure to call that local token endpoint. That’s exactly why this demo series needs federated credentials at all. None of the three CI/CD systems run on Azure infrastructure, so they can’t get a managed-identity token directly. They need a trust relationship instead, and that’s what a federated credential is.

One more wrinkle worth knowing before the demos: Azure recently extended federated credentials to user-assigned managed identities directly, not just to app registrations. That means a UAMI can now be the thing an external OIDC issuer federates into, without an app registration in the picture at all. One of the Azure DevOps demos in the session uses exactly that. More on it in section 4.

2. Why we use them

The alternative to either of these is a service account with a password, or an app registration with a client secret/certificate, both handed out to whatever system needs to automate something. That pattern has three recurring problems:

  1. The credential is long-lived and has to be actively managed. Someone has to rotate it before it expires, store it somewhere safe, and make sure it doesn’t end up in a commit, a log line, or a build artifact.
  2. Possession equals access. If the secret leaks, whether through a misconfigured log, a public repo, or a compromised laptop, whoever has it can authenticate as that identity from anywhere, with no further checks, until someone notices and revokes it.
  3. Blast radius is hard to reason about. A single shared app registration used by multiple pipelines means a compromise of the weakest one compromises all of them.

App registrations and managed identities solve the identity half of this cleanly: instead of a human or a shared secret representing “the automation”, there’s a distinct principal per purpose, with its own permissions, its own audit trail in sign-in logs, and its own ability to be individually revoked. That’s why each CI/CD system in this demo gets its own app registration or managed identity rather than one shared identity. A leaked or misconfigured pipeline in one system can’t be used to mint tokens that impersonate a different system’s identity.

What app registrations and managed identities don’t solve on their own is the credential problem. An app registration with a client secret has just moved the “long-lived secret sitting in a vault” problem from a service account’s password to an app’s secret. That’s where federated credentials come in.

3. Federated credentials: passwordless, and why that’s a real security upgrade

A federated credential is not a secret at all. It’s a trust statement attached to an app registration or managed identity in Entra ID:

“I will accept an OIDC ID token as proof of identity, but only if it was issued by this specific issuer, and only if its subject claim equals this specific value.”

Concretely, on the Entra side, a federated credential has three fields:

Field Meaning
Issuer The OIDC token issuer that Entra will trust: GitHub’s, Azure DevOps’, or GitLab’s own issuer, depending on the platform
Subject An exact-match string identifying which workload is trusted: a specific repo+branch, a specific Azure DevOps service connection, a specific GitLab project+branch
Audience Normally the fixed value Entra expects for this kind of exchange

At run time, the flow looks like this everywhere:

  1. The CI system mints a short-lived ID token (a JWT) for the currently running job, signed by the CI platform’s own OIDC issuer. It’s created fresh per job and typically expires within minutes.
  2. The pipeline presents that ID token to Entra ID’s token endpoint as a client assertion, in an OAuth2 client-credentials exchange.
  3. Entra ID validates the token’s signature against the issuer’s published keys, then checks the subject claim against the federated credential’s configured value. If it matches, it returns a normal Entra ID access token for the app or managed identity, scoped to whatever resource/audience was requested (Microsoft Graph, SharePoint, ARM, and so on).
  4. No secret was created, stored, retrieved, or transmitted at any point in that exchange.

This is why it’s meaningfully more secure than a stored secret, not just a buzzword:

  • Nothing to steal. There is no static value anywhere, not in a vault, not in an environment variable, not in a log, that by itself grants access. The proof of identity is a token minted fresh by the CI platform for that specific job run, and it typically can’t be reused outside the context it was issued in.
  • The trust is narrow and explicit. The subject claim ties the credential to one exact repo/branch, one exact service connection, or one exact project/branch. A token from a different repo, a fork, or a different branch simply won’t match and gets rejected.
  • Nothing to rotate. There’s no expiry date on the federated credential itself to track; the short-lived tokens exchanged at run time expire on their own, constantly, by design.
  • Compromise is bounded and revocable in one place. If a pipeline is compromised, the attacker only ever gets tokens matching that pipeline’s exact subject, and you revoke access by deleting one federated credential, with nothing else to rotate elsewhere.

The two things sitting on top of a federated credential, an app registration or a managed identity, are what actually get authorized to do something (via API permissions, Azure RBAC, etc.). The federated credential is purely the “how do you prove you’re allowed to ask for a token” layer.

4. The demos: the same pattern, three different plumbing styles

Each platform’s demo in the session does a variation of the same three things against a SharePoint tenant, using Microsoft Graph as the calling surface throughout. The app registrations involved hold only Graph application permissions to read and create sites, no SharePoint API permission at all:

  1. Graph native read: exchange the pipeline’s OIDC token for a Graph token directly with the platform’s own tooling, and read a known site plus its document libraries. This is the “prove the federation works, no PnP involved” step.
  2. PnP read: connect PnP PowerShell using that same federated identity, then re-do the read through PnP’s own Graph helper.
  3. PnP create: same connection, but create a brand-new SharePoint site via Graph, the operation that actually needs write access.

What differs across platforms is how step 1’s token exchange happens. Each CI system exposes OIDC differently, and that’s genuinely the most instructive part to walk through live.

GitHub Actions

GitHub’s OIDC support is the most “batteries included” of the three. A job that opts in to requesting an ID token gets one minted automatically by GitHub the moment it starts, and the official Azure login action for GitHub Actions handles the entire federated exchange without any manual token handling. You give it the app’s client ID and tenant ID, and it comes back with an authenticated session.

A minimal version of that job looks like this:

permissions:
  id-token: write
  contents: read

jobs:
  sharepoint-demo:
    runs-on: ubuntu-latest
    steps:
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          allow-no-subscriptions: true

      - name: Acquire a Microsoft Graph token
        run: |
          TOKEN=$(az account get-access-token --resource-type ms-graph --query accessToken -o tsv)

The Entra-side federated credential trusts GitHub’s OIDC issuer, scoped to a subject that encodes the exact repository and branch, meaning only workflow runs from that exact repo and branch can ever get a token for this app. Forks are deliberately excluded: they get a different subject and simply won’t match.

The PnP PowerShell variant of the same job skips the login action entirely and connects directly with a “federated identity” mode instead of a manually supplied bearer token:

Connect-PnPOnline -Url "https://contoso-admin.sharepoint.com" `
  -ClientId $env:AZURE_CLIENT_ID -Tenant $env:AZURE_TENANT_ID -FederatedIdentity

Under the hood, PnP PowerShell detects it’s running in GitHub Actions via the environment variables GitHub injects for OIDC, fetches the ID token itself, and does the Entra exchange internally, with no azure/login step needed at all in this path.

Azure DevOps, and the managed-identity variant

Azure DevOps routes its workload identity federation through an Azure Resource Manager service connection, even though this demo never touches an ARM resource. The service connection is just the vehicle that carries “which principal plus which federated credential” into a pipeline. The session actually demonstrates two variants side by side, which maps directly onto the app-registration-vs-managed-identity distinction from section 1:

  • One where the service connection is configured with workload identity federation against a plain app registration. Azure DevOps generates an issuer and subject specific to that one service connection, which gets pasted into the app’s federated credential as a custom issuer entry.
  • One where the service connection instead points at a user-assigned managed identity, using Entra’s newer support for attaching federated credentials directly to a UAMI. There is no app registration anywhere in this path: the managed identity itself is the principal that Azure DevOps’ OIDC token federates into, and the same UAMI can be reused across multiple service connections or pipelines without duplicating an app registration for each.

Both pipelines drive the same three-stage demo, and the federated exchange happens transparently the moment a pipeline task references the service connection. From the task’s point of view, it just gets an already-authenticated Azure CLI session to pull a Graph token from:

- task: AzureCLI@2
  inputs:
    azureSubscription: $(AZURE_SERVICE_CONNECTION)
    scriptType: bash
    scriptLocation: inlineScript
    inlineScript: |
      TOKEN=$(az account get-access-token --resource-type ms-graph --query accessToken -o tsv)

That YAML is identical whether $(AZURE_SERVICE_CONNECTION) points at the app-registration variant or the managed-identity one. The swap happens entirely in how the service connection itself was set up, not in the pipeline that consumes it.

Showing both variants back to back in the call is a good way to make the app-reg-vs-managed- identity distinction concrete: identical pipeline logic, only the kind of principal behind the service connection changes.

GitLab CI/CD

GitLab has no built-in “log into Azure” action, so this demo does the OIDC-to-Entra exchange more explicitly. It takes more steps, but every part of the mechanism is visible instead of hidden behind an action. GitLab’s own OIDC feature is what mints the ID token for the job and exposes it as a job variable with a configurable audience:

id_tokens:
  GITLAB_OIDC_TOKEN:
    aud: api://AzureADTokenExchange

That token is then handed to the Azure CLI’s own federated-token login for the native Graph read stage:

az login --service-principal -u "$AZURE_CLIENT_ID" -t "$AZURE_TENANT_ID" \
  --federated-token "$GITLAB_OIDC_TOKEN" --allow-no-subscriptions

and to PnP PowerShell’s federated-identity connection mode for the PnP stages, which, as of very recently (see section 5), does the same token exchange internally once it detects it’s running inside GitLab CI/CD, using the same -FederatedIdentity call shown in the GitHub Actions section above.

The Entra federated credential here trusts GitLab’s issuer (gitlab.com, or a self-managed instance’s own URL) with a subject that encodes the exact project and branch: the GitLab equivalent of GitHub’s repo-and-branch scoping.

Going further: a fourth, cross-tenant variant

There’s a more advanced demo that isn’t part of the three-CI-system core, but is worth a mention if there’s time in the call: a Logic App (Consumption) using a system-assigned managed identity token to federate into a multitenant app registration, which has been admin-consented into a different Entra tenant. This variant replaced two earlier dead ends: Azure DevOps can’t federate through a managed identity a second time (Entra refuses to chain two federated exchanges), and Azure DevOps’ current OIDC issuer flatly rejects multitenant apps. A managed identity’s genuine token sidesteps both, because it was never federation-derived in the first place.

The original plan was to create a SharePoint site directly in the target tenant through the beta Graph site-creation endpoint, the same one behind the -UseGraph work in section 5. In practice that path turned out to be unreliable for a cross-tenant call from a system-assigned managed identity, so the demo creates a Microsoft 365 Group in the target tenant instead, using the Graph application permission Group.Create. Provisioning a group is a stable, non-beta Graph operation, and Microsoft 365 automatically provisions a connected SharePoint team site as part of creating the group, so the end result (a new SharePoint site in a tenant the Logic App doesn’t live in, with zero secrets anywhere) is the same, just reached through group creation rather than a direct site-creation call.

5. Extra: contributing the missing pieces back to PnP PowerShell and CLI for Microsoft 365

While building the GitLab and site-creation demos, three gaps turned up: one shared across two different open-source projects, and one in site creation itself. All three are real contributions, not just demo glue.

GitLab CI/CD support for PnP PowerShell’s federated-identity login

Before this change, PnP PowerShell’s federated-identity connection mode only recognized GitHub Actions and Azure DevOps. GitLab CI/CD simply wasn’t a supported context, even though GitLab’s own OIDC feature produces exactly the same kind of ID token the other two platforms use. The fix, merged upstream as PR #5395 , detects GitLab CI/CD via its platform-specific environment variables and feeds that token into the same federated-exchange code path already used for GitHub Actions and Azure DevOps:

else if (!string.IsNullOrWhiteSpace(gitlabCi) && !string.IsNullOrWhiteSpace(gitlabOidcToken))
{
    if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(tenant))
    {
        throw new PSInvalidOperationException(
            "ClientId and Tenant must be provided when using Federated Identity in GitLab CI/CD.");
    }

    // GitLab's id_tokens feature mints the OIDC JWT directly into the configured job
    // variable, so no separate request to GitLab is needed before exchanging it with Entra ID.
    return await GetAccessTokenWithFederatedTokenAsync(clientId, tenant, requiredScope, gitlabOidcToken);
}

The one real difference between GitLab and GitHub in that code path: GitHub’s token has to be fetched from a GitHub-provided endpoint first, while GitLab hands you the finished token directly as a job variable, so GitLab’s branch skips straight to the Entra exchange. With this merged, the federated-identity connection mode now genuinely means “figure out which of the three platforms I’m running in and do the right thing”, which is exactly what the GitLab demo’s PnP stages rely on.

The same fix, again, in CLI for Microsoft 365

The GitLab CI/CD gap wasn’t unique to PnP PowerShell. CLI for Microsoft 365 (the m365 CLI) has its own, independent federated-identity login path, and it had exactly the same blind spot: it recognized GitHub Actions and Azure DevOps and rejected anything else, GitLab included. The fix is the same shape, ported to the CLI’s own TypeScript codebase:

else if (process.env.GITLAB_CI && process.env.GITLAB_OIDC_TOKEN) {
  if (!this.connection.appId || this.connection.tenant === 'common') {
    throw new CommandError(
      'The appId and tenant parameters are required when using Federated Identity in GitLab CI/CD.');
  }

  // GitLab's id_tokens feature mints the OIDC JWT directly into the configured job variable,
  // so no separate request to GitLab is needed before exchanging it with Entra ID.
  return this.getAccessTokenWithFederatedToken(resource, process.env.GITLAB_OIDC_TOKEN, logger, debug);
}

I validated it the same way I validated the PnP PowerShell fix during the demo build: running the GitLab pipeline’s login step against the current stable m365 release first (which fails, confirming the gap is real) and then against a custom build carrying this change (which succeeds). Like the site-creation work below, this one is implemented and proven out in the demo but not yet merged upstream. It’s waiting on a pull request the same way #5395 started out for PnP PowerShell.

Creating sites without a SharePoint permission

The third piece is still working its way upstream into PnP PowerShell. Site creation has always gone through SharePoint CSOM, which means the calling app registration needs a SharePoint API permission, because creating a new site collection is a tenant-admin operation that the narrower, per-site Graph permission can’t cover (there’s nothing to scope a per-site grant to before the site exists). None of this demo’s app registrations carry that SharePoint permission at all; they only hold Graph permissions, which is exactly why site creation needed a new code path rather than the existing one.

A new switch, added on top of PnP PowerShell’s existing site-creation cmdlet, creates the site through Microsoft Graph’s own site-creation API instead, which only needs the Graph application permission to create sites, a permission that lives entirely in Graph’s permission model, with no SharePoint API permission involved at all:

New-PnPSite -Type CommunicationSite -Title Contoso `
  -Url https://contoso.sharepoint.com/sites/contoso -UseGraph

There’s a real nuance worth calling out, because it’s the kind of thing you only find by actually trying to run it least-privilege: submitting the creation request only needs that one Graph permission, but if you also want the cmdlet to wait until SharePoint has finished provisioning the site, checking that provisioning status requires a SharePoint-audience token instead, because the (currently beta) Graph site-creation API exposes its status endpoint on the tenant’s own SharePoint domain, not on Graph itself. A per-site Graph grant can’t substitute here either, for the same reason it can’t work for creation itself: it can only be granted against a site that already exists. So the honest tradeoff is: without waiting for completion, site creation genuinely only needs the one Graph permission; with it, you’re back to needing a broader SharePoint permission, which somewhat defeats the minimal-permission point of using Graph in the first place. The demo’s create-site stage skips waiting for completion for exactly that reason, and treats the returned URL as “creation submitted” rather than “creation confirmed complete.”

The demo’s create-site app registration actually carries both the Graph permission to create sites and a narrower, per-site Graph permission, and Graph grants that app the per-site permission on the new site the moment it’s created. It’s a live example of the “tighten to a per-site grant for day-2 operations once the site exists” idea: the identity keeps the broad, tenant-wide permission only for the one moment it’s unavoidable (creation), and everything afterward can run on the narrow, per-site grant instead.

Wrapping up

The pattern underneath all of this is the same regardless of which CI/CD system you use: give the pipeline its own identity (an app registration or a managed identity), let Entra ID trust that identity’s proof of run via a federated credential scoped to exactly one repo/project/branch, and never let a secret exist in the first place. GitHub, Azure DevOps, and GitLab each expose the OIDC half of that handshake slightly differently, but both PnP PowerShell’s federated-identity login and CLI for Microsoft 365’s now understand all three platforms. And the new Graph-based site creation means the whole thing, including the final “create a site” step, can eventually run on a single Graph permission instead of a broad SharePoint one.