Backstage with Azure: Microsoft Entra ID Authentication

Set up Backstage authentication with Microsoft Entra ID and OpenTofu. Import users and groups from Microsoft Graph and replace guest access in your local app.

Category
Platform Engineering
Published
Reading time
14 minutes
Tags
backstage, platform-engineering, microsoft-entra

Backstage is an open-source framework built by Spotify and donated to the Cloud Native Computing Foundation (CNCF) for building developer portals. It gives you a place to bring together your software catalog, documentation, and developer tools.

Backstage provides a CLI to scaffold a new app and run it locally. The default guest sign-in and example catalog are useful for taking a look around. But some of you might want to eventually run this within your Azure environment, and that's what this new Backstage with Azure series is about.

In this first article, I'll walk you through creating a Backstage app, configuring Microsoft Entra ID sign-in, and importing users and groups into the catalog with Microsoft Graph. We'll keep the app local for this first part.

Prerequisites

Before you begin, make sure your local developer environment is set up with the necessary tools.

  • Node.js 24 with npm. The example app also supports Node.js 22.
  • Git, Python 3, and the native build tools listed in the Backstage prerequisites. Use Linux, macOS, or Windows with WSL.
  • Yarn 4 and Corepack. We'll enable Corepack below; install it first if your Node.js installation does not include it.
  • OpenTofu or Terraform and the Azure CLI.
  • A Microsoft Entra ID test tenant and an account permitted to create app registrations, security groups, and delegated permission grants, and to consent to Microsoft Graph application permissions. Use this same account throughout the walkthrough.

The email-based resolver used in this example needs a populated Microsoft Graph mail value that matches the email returned during sign-in. If your account has no email, use the user-ID resolver option described below.

My recommendation is to use a test tenant because the catalog integration requests directory-wide read permissions. The account running OpenTofu must be authorized to grant them.

A Privileged Role Administrator can grant Microsoft Graph application permissions; Application Administrator and Cloud Application Administrator cannot. Check the admin consent prerequisites and activate any required eligible role before continuing. Being an Owner of an Azure subscription is not the same thing.

The sample code works with Terraform too; replace tofu with terraform in the commands if that's what you use.

The complete examples are in my Backstage app and Backstage Azure configuration repositories. The app repository uses Backstage 1.55.0 and is a reference for the finished setup; we'll scaffold a new app below. Files generated by @latest may change as Backstage releases new versions.

Step 1: Scaffold a new Backstage application

Let's start by creating a parent directory. The Backstage app and OpenTofu configuration will live in separate directories underneath it.

mkdir backstage-with-azure
cd backstage-with-azure

Backstage uses Yarn 4 as its package manager. Enable Corepack so the yarn command uses the version configured by the generated app.

corepack enable

Run the Backstage scaffolder to generate the application and install its dependencies.

npx @backstage/create-app@latest

Proceed through the prompts.

I used the default name of backstage. If you choose something else, adjust the directory paths throughout this post, including the output path in the OpenTofu configuration.

You'll see a series of status messages as the app is created. This can take a few minutes while the dependencies are installed.

If you're lucky (or unlucky, depending on how you look at it), installation may stop with an error about a missing got patch. I've put the workaround under Yarn gotchas. If installation succeeded, carry on.

Start the app

Change into the app directory and run the app.

cd backstage && yarn start

The yarn start command starts both the frontend and backend. By default, Backstage loads app-config.yaml and merges in app-config.local.yaml if that file exists. We'll generate the local configuration file later.

If startup complains about approvedGitRepositories, follow the Yarn version fix, then return to the check below.

Check the app

Once startup completes, open http://localhost:3000. The backend runs at http://localhost:7007. If the welcome page shows a Guest sign-in card, click Enter to explore the demo.

Congratulations! You just scaffolded a Backstage app. The guest identity is enough to explore the example catalog, but it is not connected to an account in your Entra tenant.

Stop the app with Ctrl+C before moving on.

Step 2: Configure Microsoft Entra ID with OpenTofu

We need two integrations here, and they do different jobs:

  • The Microsoft authentication provider lets a developer sign in using their Entra account.
  • The Microsoft Graph catalog provider imports users and groups so Backstage has catalog identities to associate with those sign-ins.

The sign-in resolver connects the two. Successfully signing in to Microsoft does not automatically create a Backstage catalog user. With the resolver used here, that user must already be in the catalog.

I want this Entra configuration to be repeatable, so I'm using OpenTofu to define it as code.

Prepare the OpenTofu configuration

If you are still in the backstage app directory, let's back out of that and return to the parent directory.

cd ..

Create a new directory called backstage-azure to store your OpenTofu configuration files. Keeping it beside backstage matters because the configuration writes a file into the app using a relative path.

mkdir backstage-azure && cd backstage-azure

Create a main.tf file inside the backstage-azure directory.

touch main.tf

Copy the complete main.tf from my Backstage Azure configuration repository into this file. The snippets below walk through its authentication configuration rather than repeat every resource.

Copy the repository's .gitignore into backstage-azure too. We created this directory ourselves, so it does not have the repository's exclusions yet.

The complete file pins the AzureAD provider and creates a security group named backstage-users. It adds the user running OpenTofu as a direct member of that group. This is the azuread_group.backstage_users resource referenced later.

I'd normally split this across a few files, but we'll keep everything in main.tf for this walkthrough.

Look up the tenant and Microsoft Graph permissions

Let's look at the next few lines. The azuread_client_config data block reads the identity running OpenTofu. The azuread_service_principal data block looks up Microsoft Graph using its well-known client ID, 00000003-0000-0000-c000-000000000000. The locals let us look up delegated permission IDs by name and list the application permissions needed for catalog imports.

data "azuread_client_config" "current" {}
 
data "azuread_service_principal" "msgraph" {
  client_id = "00000003-0000-0000-c000-000000000000"
}
 
locals {
  msgraph_oauth2_permission_scope_ids = {
    for scope in data.azuread_service_principal.msgraph.oauth2_permission_scopes : scope.value => scope.id
  }
 
  # Application permissions used by the catalog msgraph provider to import users and groups
  msgraph_app_roles = ["User.Read.All", "GroupMember.Read.All", "Group.Read.All"]
}

Sign-in uses delegated permissions because Backstage is acting on your behalf. The catalog importer runs without a signed-in user, so it uses application permissions.

Create the Backstage group

Before creating the app registration, we need a dedicated group for Backstage users. We'll import its members into the catalog so Backstage can resolve their identities when they sign in. The user running OpenTofu is added as a direct member.

resource "azuread_group" "backstage_users" {
  display_name     = "backstage-users"
  security_enabled = true
  owners           = [data.azuread_client_config.current.object_id]
}
 
resource "azuread_group_member" "me" {
  group_object_id  = azuread_group.backstage_users.object_id
  member_object_id = data.azuread_client_config.current.object_id
}

Create the app registration and client secret

The time_rotating resource provides the timestamp used by the client secret below. The example sets a 180-day interval.

resource "time_rotating" "example" {
  rotation_days = 180
}

The app registration follows the Microsoft authentication provider documentation.

The app accepts accounts from this tenant through AzureADMyOrg. Its homepage points to the frontend on port 3000, but its redirect URI points to the backend on port 7007. That backend callback is where Backstage handles the response from Microsoft.

resource "azuread_application" "example" {
  display_name     = "backstage"
  owners           = [data.azuread_client_config.current.object_id]
  sign_in_audience = "AzureADMyOrg"
 
  web {
    homepage_url = "http://localhost:3000/"
    redirect_uris = [
      "http://localhost:7007/api/auth/microsoft/handler/frame"
    ]
  }
 
  required_resource_access {
    resource_app_id = data.azuread_service_principal.msgraph.client_id
 
    resource_access {
      id   = local.msgraph_oauth2_permission_scope_ids["openid"]
      type = "Scope"
    }
 
    resource_access {
      id   = local.msgraph_oauth2_permission_scope_ids["profile"]
      type = "Scope"
    }
 
    resource_access {
      id   = local.msgraph_oauth2_permission_scope_ids["email"]
      type = "Scope"
    }
 
    resource_access {
      id   = local.msgraph_oauth2_permission_scope_ids["User.Read"]
      type = "Scope"
    }
 
    resource_access {
      id   = local.msgraph_oauth2_permission_scope_ids["offline_access"]
      type = "Scope"
    }
 
    dynamic "resource_access" {
      for_each = local.msgraph_app_roles
      content {
        id   = data.azuread_service_principal.msgraph.app_role_ids[resource_access.value]
        type = "Role"
      }
    }
  }
 
  password {
    display_name = "backstage"
    start_date   = time_rotating.example.id
    end_date     = timeadd(time_rotating.example.id, "4320h")
  }
}

The Scope entries request the delegated permissions used by the sign-in flow. The dynamic Role entries request User.Read.All, GroupMember.Read.All, and Group.Read.All for the catalog integration. Finally, the password block generates the client secret, with 4320h setting its lifetime to 180 days.

From there the service principal is created. The app registration describes the application; the service principal represents that application inside this tenant and is where permission grants apply.

resource "azuread_service_principal" "example" {
  client_id = azuread_application.example.client_id
}

Requesting permissions in the app registration does not grant them. The next two resources handle that: azuread_service_principal_delegated_permission_grant grants the delegated permissions for sign-in, and azuread_app_role_assignment grants the application permissions for catalog imports.

resource "azuread_service_principal_delegated_permission_grant" "example" {
  service_principal_object_id          = azuread_service_principal.example.object_id
  resource_service_principal_object_id = data.azuread_service_principal.msgraph.object_id
  claim_values                         = ["openid", "profile", "email", "User.Read", "offline_access"]
}
 
# Grant admin consent for app roles (application permissions)
resource "azuread_app_role_assignment" "example" {
  for_each            = toset(local.msgraph_app_roles)
  app_role_id         = data.azuread_service_principal.msgraph.app_role_ids[each.key]
  principal_object_id = azuread_service_principal.example.object_id
  resource_object_id  = data.azuread_service_principal.msgraph.object_id
}

Both grants are managed by OpenTofu. That's why the account running tofu apply needs the directory permissions listed in the prerequisites; there is no separate portal consent step in this walkthrough.

Generate the local Backstage configuration

Backstage needs the client ID, tenant ID, client secret, and group ID. Rather than copy them by hand, we'll use local_file and templatefile to write backstage/app-config.local.yaml. The group ID tells the catalog provider which group to import.

resource "local_file" "backstage_values" {
  filename = "../backstage/app-config.local.yaml"
  content = templatefile("backstage-app-config.tmpl",
    {
      AZURE_CLIENT_ID     = azuread_application.example.client_id
      AZURE_CLIENT_SECRET = tolist(azuread_application.example.password).0.value
      AZURE_TENANT_ID     = data.azuread_client_config.current.tenant_id
      AZURE_GROUP_ID      = azuread_group.backstage_users.object_id
    }
  )
}

Create backstage-app-config.tmpl alongside main.tf with the following content. The ${...} values are OpenTofu template variables here. OpenTofu substitutes them when it generates the YAML file, so you do not need to export these four values as environment variables.

auth:
  environment: development
  providers:
    microsoft:
      development:
        clientId: ${AZURE_CLIENT_ID}
        clientSecret: ${AZURE_CLIENT_SECRET}
        tenantId: ${AZURE_TENANT_ID}
        domainHint: ${AZURE_TENANT_ID}
        signIn:
          resolvers:
            - resolver: emailMatchingUserEntityAnnotation
catalog:
  providers:
    microsoftGraphOrg:
      default:
        tenantId: ${AZURE_TENANT_ID}
        clientId: ${AZURE_CLIENT_ID}
        clientSecret: ${AZURE_CLIENT_SECRET}
        userGroupMember:
          filter: id eq '${AZURE_GROUP_ID}'
        group:
          filter: id eq '${AZURE_GROUP_ID}'
        schedule:
          frequency: { minutes: 30 }
          timeout: { minutes: 3 }

The auth section configures the Microsoft provider for the development environment. The domainHint helps direct the sign-in flow to the intended tenant.

I'm using emailMatchingUserEntityAnnotation in this example. It matches the email returned during sign-in to the catalog User's microsoft.com/email annotation. The Microsoft Graph importer adds that annotation only when the user's mail property is populated. An account without it can still be imported, but email-based sign-in matching will fail.

For accounts without email, use userIdMatchingUserEntityAnnotation instead. It matches the Microsoft user profile ID to the catalog User's graph.microsoft.com/user-id annotation, so it does not depend on email. Both resolvers require a matching catalog user by default. The Backstage Microsoft sign-in resolver documentation explains the available options.

If you choose the user-ID option, change the resolver name in backstage-app-config.tmpl and rerun tofu apply before restarting Backstage. That keeps the generated app-config.local.yaml in sync with the template.

Under catalog, userGroupMember imports the direct user members of backstage-users, while group imports the group itself. Note that this configuration does not expand nested group membership. The group filters limit what gets imported into the catalog and help control the scope of the import. The schedule runs the import every 30 minutes with a three-minute timeout, so membership changes are not reflected immediately.

Backstage loads and merges the local configuration automatically during normal local startup.

The Backstage app ignores *.local.yaml, and the infrastructure .gitignore we copied excludes state files. Keep those exclusions: both the generated YAML and OpenTofu state contain the client secret. Treat saved plans as sensitive too.

From the backstage-azure directory, sign in with the account that has the required directory permissions. This is also the account we'll add to the demo group and use to sign in to Backstage. Replace <your-tenant-id> with the directory ID of your test tenant. The --allow-no-subscriptions flag allows a tenant-only login; this configuration does not deploy subscription resources.

az login --tenant "<your-tenant-id>" --allow-no-subscriptions
tofu init
tofu plan
tofu apply

Review the plan before confirming the apply. If all went well, you should have a backstage app registration, a backstage-users group containing your account, and an app-config.local.yaml file in the Backstage app directory.

If the apply fails with a permission error, stop here. Confirm that the required role is active for the account you used, then rerun tofu apply from the same directory with the same state. Don't grant permissions manually to work around a failed apply; these grants belong to the OpenTofu configuration.

After the apply completes successfully, open the Microsoft Entra admin center, select App registrations, open backstage, and select API permissions. Confirm that the Microsoft Graph application permissions show as granted. Without them, Microsoft sign-in can work while the catalog import fails with insufficient privileges.

Step 3: Configure the Backstage app

Now we can connect the app to the configuration we just generated. From the backstage-azure directory, run cd ../backstage. All of the following file paths are relative to the Backstage app root.

Install the authentication and catalog provider packages in the backend workspace.

yarn --cwd packages/backend add \
  @backstage/plugin-auth-backend-module-microsoft-provider \
  @backstage/plugin-catalog-backend-module-msgraph

Open packages/backend/src/index.ts and find the guest provider registration:

backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));

Replace it with these two registrations. Leave the existing @backstage/plugin-auth-backend registration and the other backend plugins in place.

backend.add(import('@backstage/plugin-auth-backend-module-microsoft-provider'));
backend.add(import('@backstage/plugin-catalog-backend-module-msgraph'));

Next, update the sign-in page in packages/app/src/App.tsx. This example uses Backstage's new frontend system and SignInPageBlueprint, rather than the older frontend routing examples you may find elsewhere.

For the freshly scaffolded app, replace App.tsx with the complete file below. It keeps both navModule and homeModule, including their imports. If you're adapting an existing app instead, keep any additional feature registrations you've added.

import { createApp } from '@backstage/frontend-defaults';
import catalogPlugin from '@backstage/plugin-catalog/alpha';
import { navModule } from './modules/nav';
import { homeModule } from './modules/home';
import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
import { SignInPageBlueprint } from '@backstage/plugin-app-react';
import { SignInPage } from '@backstage/core-components';
import { createFrontendModule } from '@backstage/frontend-plugin-api';
 
const signInPage = SignInPageBlueprint.make({
  params: {
    loader: async () => props => (
      <SignInPage
        {...props}
        provider={{
          id: 'microsoft-auth-provider',
          title: 'Microsoft Entra ID',
          message: 'Sign in using Microsoft Entra ID',
          apiRef: microsoftAuthApiRef,
        }}
      />
    ),
  },
});
 
export default createApp({
  features: [
    catalogPlugin,
    navModule,
    homeModule,
    createFrontendModule({
      pluginId: 'app',
      extensions: [signInPage],
    }),
  ],
});

The blueprint replaces the sign-in page with a Microsoft Entra sign-in option. microsoftAuthApiRef connects that UI to the Microsoft authentication provider, and the frontend module registers the page with the app. The actual authentication flow is still handled by the backend.

Step 4: Remove guest access and example catalog data

Now that Microsoft sign-in is wired up, let's remove the demo configuration. There are two separate things to clean up: the guest authentication option and the sample catalog entities.

In app-config.yaml, remove guest from auth.providers. Leave providers as an empty mapping, {}, if there are no other providers in that file. The Microsoft provider settings will come from app-config.local.yaml. See app-config.yaml for reference.

Check app-config.production.yaml for a guest provider entry too and remove it if present. Backstage merges configuration objects, so adding a Microsoft provider in one file does not remove a guest provider declared in another. Next, remove the entire catalog block that loads example data from app-config.yaml and app-config.production.yaml.

Finally, delete the examples directory from the Backstage app root. Remove its configuration references first so the catalog does not keep trying to load files that no longer exist.

Removing the example users is not a substitute for removing guest authentication. We replaced the backend guest module and the frontend sign-in page in the previous step; this step cleans up the remaining configuration and sample data.

Step 5: Sign in with Microsoft Entra ID

From the Backstage app directory, start the app again.

yarn start

Watch the backend logs for Reading msgraph users and groups and a completed import. Give the first import time to finish before signing in. The development setup uses an in-memory database, so the catalog needs to be populated again after a restart.

Open http://localhost:3000 in a private browser window to avoid reusing the earlier guest session. You should see the Microsoft Entra sign-in option rather than the Guest card. Sign in with the account added to backstage-users.

If all went well, you should be signed in to Backstage 🥳 You should be able to navigate to http://localhost:3000/catalog/default/group/backstage-users to view the imported group and its members.

If something is not working, check which part of the flow failed:

  • Microsoft reports a redirect URI mismatch: Check the app registration's Web redirect URI. It must match http://localhost:7007/api/auth/microsoft/handler/frame, including the backend port and path.
  • Microsoft sign-in succeeds, but Backstage cannot resolve your identity: Check direct group membership, the catalog import logs, and the imported User's microsoft.com/email annotation. The email needs to match what the authentication provider returns. If the account has no email, follow the user-ID resolver option.
  • Microsoft Graph reports insufficient privileges: Check that the application permissions, not just the delegated permissions, have admin consent.

One important distinction: we have configured authentication and catalog identities, not a policy for what each user is allowed to do. The example backend still uses Backstage's allow-all permission policy. Group imports give us identity and membership data to work with, but do not create role-based access rules on their own.

Yarn gotchas

These fixes are for two specific errors. If your app installs and starts without them, skip this section.

Missing got patch

The scaffolder may stop during yarn install with an error like this:

➤ YN0000: · Yarn 4.13.0
➤ YN0001: │ Error: got@patch:got@npm%3A11.8.2#~/.yarn/patches/got-npm-11.8.2-c1eb105458.patch: ENOENT: no such file or directory
Error: Could not execute command yarn install

This error is tracked in yarnpkg/berry#7281. The affected dependency references a patch file from Yarn's own repository that is not available in the generated app.

The workaround used in the example app is to pin the @yarnpkg/core dependency to 4.9.1. Merge this entry into the existing resolutions object in backstage/package.json. Keep the other entries; this is not a replacement for the whole package.json.

{
  "@yarnpkg/core": "4.9.1"
}

From the backstage-with-azure parent directory, finish installing dependencies:

yarn --cwd backstage install

If that command reports an unrecognized approvedGitRepositories setting, use the next fix. Once installation succeeds, return to Start the app.

Unrecognized approvedGitRepositories setting

If Yarn rejects the generated configuration, you may see:

Usage Error: Unrecognized or legacy configuration settings found: approvedGitRepositories - run "yarn config" to see the list of settings supported in Yarn

This is a version mismatch, not a leftover Yarn 1 setting. Yarn's Git dependency controls include approvedGitRepositories, but Yarn 4.13.0, used in the reference app, does not recognize it. Yarn 4.18.1 does.

Keep the setting. From the Backstage app root, upgrade the project's Yarn version, install dependencies, and start again:

YARN_IGNORE_PATH=1 corepack yarn@4.18.1 set version 4.18.1
yarn --version
yarn install
yarn start

The first command temporarily bypasses an older Yarn binary selected by yarnPath and records the new version for future commands. yarn --version should now print 4.18.1. Upgrading from inside the app matters: its packageManager and yarnPath settings can select a different version from the one you use elsewhere.

The @yarnpkg/core resolution in the previous workaround pins a library dependency, not the Yarn CLI. They are separate version choices. Once the app starts, return to Check the app to continue the walkthrough.

Cleanup

If you plan to continue building on this setup for the series, stop the app with Ctrl+C and keep the Entra resources.

Otherwise, stop the app and return to the backstage-azure directory to remove the resources created by this configuration. Use the same account with the required directory permissions, and keep the state file until the destroy completes.

cd ../backstage-azure
tofu destroy

Review the destroy plan before confirming it. This removes the app registration, service principal, demo group, and generated local configuration file managed by OpenTofu. It does not delete your Backstage source code.

Conclusion

We now have a local Backstage app that uses Microsoft Entra ID for sign-in and Microsoft Graph to populate its users and groups. The guest sign-in and example catalog data are gone, and OpenTofu gives us a repeatable way to create the Entra configuration.

That's all that is needed for this first part. This is still a local development setup, but we have the identity configuration in place to build on as the series continues.

See you in the next one ✌️

Learn more

Community discussion