Running your own apps is great until someone in management asks why employees need yet another username and password. Then you’re on the hook for federated auth. If your org already runs Microsoft 365, Entra ID (the artist formerly known as Azure AD) is already there, already paid for, and already trusted by your users. You might as well use it.
This guide is for engineers who want to wire self-hosted apps — Grafana, Nextcloud, Gitea, custom services — into Entra ID as the identity provider. We’ll cover both OIDC and SAML, explain when each one makes sense, and go through real configuration with all the rough edges that the official docs gloss over.
No "let’s explore the possibilities" energy here. We’re configuring auth.
OIDC vs SAML: Pick the Right Protocol
Before touching the Azure portal, decide which protocol you’re using. Your app probably has one or both options. The choice matters.
OIDC (OpenID Connect) is OAuth 2.0 with an identity layer bolted on. It speaks JSON, uses bearer tokens (JWTs), and has a clean discovery document at /.well-known/openid-configuration. Modern apps default to OIDC. If the library exists, use OIDC — it’s easier to debug, easier to implement, and the token is readable without ceremony.
SAML 2.0 is XML-based, uses signed assertions, and was designed in 2005 when XML was considered elegant. A lot of enterprise software — especially anything that predates 2015 — only speaks SAML. If your app’s docs send you to a page about "Entity IDs" and "SP metadata," that’s SAML.
Rule of thumb: OIDC for anything you control or that’s less than 10 years old, SAML when you have no choice.
The Entra ID Side: App Registration vs. Enterprise Application
This is where people get confused, so let’s settle it upfront.
App Registration → You use this for OIDC. It gives you a client_id, lets you create a client_secret, and sets up redirect URIs.
Enterprise Application → You use this for SAML. The gallery has pre-configured apps (Grafana, AWS, Salesforce, etc.). For custom apps, create a "non-gallery" enterprise application.
They’re linked — every enterprise application has a backing app registration, but the portal surfaces them differently depending on what you’re configuring. Don’t mix them up and expect things to work.
Setting Up OIDC
Step 1: Create an App Registration
Go to Entra ID → App registrations → New registration.
- Name: something descriptive (
grafana-prod,nextcloud-internal) - Supported account types: For internal apps — "Accounts in this organizational directory only (Single tenant)"
- Redirect URI: Web → your app’s callback URL (e.g.,
https://grafana.yourcompany.com/login/azuread)
After creation, you’ll land on the overview page. Copy the Application (client) ID and Directory (tenant) ID — you’ll need both.
Step 2: Create a Client Secret
Go to Certificates & secrets → New client secret.
Set an expiry that matches your rotation policy. Write down the secret value immediately — Entra shows it once, and after you navigate away, it’s gone. If you miss it, you’re creating a new one.
Gotcha: Client secrets expire. This will break auth silently on a random Tuesday morning if you don’t build in rotation or monitoring. Set a calendar reminder or better yet, use a certificate credential (also available under "Certificates & secrets") — certificates don’t have the expiry UX problem and are preferred in production.
Step 3: Configure Token Claims
By default, Entra’s OIDC tokens are sparse. You might not get email in the ID token unless you configure it.
Go to Token configuration → Add optional claim → ID token, add:
emailpreferred_usernameupn(useful for some apps)
For group membership in tokens, go to Token configuration → Add groups claim. Choose "Security groups" or "All groups."
Gotcha: If a user is in more than ~200 groups, Entra stops emitting the groups claim and sets a
_claim_nameshint pointing to the Graph API instead. Your app likely can’t handle this gracefully. If you have deeply nested group structures, use App roles instead of groups — they’re always included and don’t have the 200-group limit.
Step 4: Set API Permissions
For basic SSO, the default openid, profile, email permissions under "Microsoft Graph" are enough. If you need group membership or need to call Graph yourself, add the relevant permissions and (for application-level permissions) grant admin consent.
Step 5: The Discovery URL
Your OIDC discovery URL is:
https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration
Plug your tenant ID in and hit it in a browser. You’ll get a JSON blob with all endpoints. Most OIDC libraries accept this URL and auto-configure from it — this is the canonical thing to put in your app’s config.
OIDC Config Example: Grafana
Here’s a working Grafana OIDC config via environment variables (or grafana.ini):
[auth.azuread]
name = Microsoft
enabled = true
allow_sign_up = true
# Your App Registration's client_id
client_id = xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Client secret value (rotate this, don't commit it)
client_secret = ${GRAFANA_ENTRA_CLIENT_SECRET}
# Single-tenant discovery URL
auth_url = https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize
token_url = https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token
# Restrict to specific Entra groups (object IDs, comma-separated)
# Leave empty to allow all org users
allowed_groups = xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Map Entra groups to Grafana roles
role_attribute_path = contains(groups[*], 'grafana-admins-group-id') && 'Admin' || 'Viewer'
Docker Compose fragment:
services:
grafana:
image: grafana/grafana:latest
environment:
GF_AUTH_AZUREAD_ENABLED: "true"
GF_AUTH_AZUREAD_CLIENT_ID: "${GRAFANA_CLIENT_ID}"
GF_AUTH_AZUREAD_CLIENT_SECRET: "${GRAFANA_CLIENT_SECRET}"
GF_AUTH_AZUREAD_AUTH_URL: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize"
GF_AUTH_AZUREAD_TOKEN_URL: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token"
GF_AUTH_AZUREAD_SCOPES: "openid email profile"
GF_SERVER_ROOT_URL: "https://grafana.yourcompany.com"
Gotcha: Grafana requires
GF_SERVER_ROOT_URLto be set correctly. The redirect URI in your Entra app registration must exactly match{root_url}/login/azuread. One trailing slash mismatch breaks the callback with a cryptic error.
Setting Up SAML
SAML is more ceremony. You’re exchanging XML metadata documents and signing assertions. But the conceptual model is the same: there’s an Identity Provider (Entra ID) and a Service Provider (your app).
Step 1: Create an Enterprise Application
Go to Entra ID → Enterprise applications → New application → Create your own application.
Name it, select "Integrate any other application you don’t find in the gallery."
Step 2: Configure Single Sign-On
In the app, go to Single sign-on → SAML.
You need to fill in four fields. Two of these come from your app’s docs:
| Field | What it is | Example |
|---|---|---|
| Identifier (Entity ID) | Globally unique name for your SP | https://nextcloud.yourcompany.com |
| Reply URL (ACS URL) | Where Entra posts the SAML response | https://nextcloud.yourcompany.com/apps/user_saml/saml/acs |
| Sign-on URL | Your app’s login page (optional) | https://nextcloud.yourcompany.com/login |
| Relay State | Post-auth redirect (usually optional) | leave blank |
Get the Entity ID and ACS URL from your app’s SAML SP metadata or its documentation.
Step 3: Download the IdP Metadata
After configuring SSO, Entra gives you:
- App Federation Metadata URL:
https://login.microsoftonline.com/{tenant-id}/federationmetadata/2007-06/federationmetadata.xml?appid={app-id}
This XML contains the signing certificate, SSO URL, and entity ID for Entra’s side. Most SAML-capable apps accept a metadata URL — plug this in and they’ll auto-configure. Some older apps need you to manually extract the certificate and SSO URL.
The SSO endpoint to use in manual configs:
https://login.microsoftonline.com/{tenant-id}/saml2
Step 4: Configure Claims
SAML assertions need to carry identity claims. Entra’s defaults:
emailaddress→user.mailname(NameID) →user.userprincipalname
Your app might expect specific attribute names. Common ones to add under "Attributes & Claims":
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress → user.mail
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/displayname → user.displayname
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/groups → user.groups
Gotcha: NameID format matters. Most apps want
emailAddressformat (the UPN). Some older apps requirepersistentortransientformat. Get this wrong and your app either fails authentication silently or creates a new account on every login. Check your app docs for the expected NameID format before going live.
SAML Config Example: Nextcloud
Nextcloud uses the user_saml app. In the admin settings:
# Identity Provider
idp.entityId = https://sts.windows.net/{tenant-id}/
idp.ssoUrl = https://login.microsoftonline.com/{tenant-id}/saml2
idp.x509cert = <certificate from Entra metadata — base64, no headers>
# Service Provider
sp.entityId = https://nextcloud.yourcompany.com
sp.nameIdFormat = urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
# Attribute mapping
general.uid_mapping = http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
general.displayName_mapping = http://schemas.xmlsoap.org/ws/2005/05/identity/claims/displayname
general.email_mapping = http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
Gotcha: Nextcloud creates a local account linked to the SAML NameID on first login. If you change the NameID format later, existing users won’t match and will get new accounts — or be locked out. Decide on NameID format before the first real user logs in.
Production-Ready Hardening
Getting auth to work in dev is one thing. Here’s what you need to not get paged at 3am.
Use a Certificate Instead of a Client Secret (OIDC)
Secrets expire and require manual rotation. Certificate credentials use asymmetric keys — your app holds the private key, Entra verifies via the public certificate.
Generate a self-signed cert (valid 2 years):
openssl req -x509 -newkey rsa:2048 -keyout entra-app.key \
-out entra-app.crt -days 730 -nodes \
-subj "/CN=my-app-entra-credential"
Upload entra-app.crt to your App Registration under "Certificates & secrets → Certificates". Store entra-app.key in Vault or a secrets manager. Configure your app to use it. No more expiry surprises.
Restrict Access with Assignment Required
By default, any user in your tenant can authenticate to your app registration. For internal tools, you almost certainly want to restrict access.
In the Enterprise Application (not the App Registration) → Properties → Assignment required = Yes.
Then under Users and groups, add the specific users or security groups that should have access. Anyone not in that list gets a polished "AADSTS50105: The signed in user is not assigned to a role for the application" error instead of a successful login.
This is a must-have for production. Don’t skip it.
Monitor Token Expiry and Sign-In Failures
Entra has built-in sign-in logs under Monitoring → Sign-in logs. Filter by your application name. You’ll see failed logins, the exact error codes, and which user triggered them.
Set up a Diagnostic Settings export to a Log Analytics workspace if you want alerting. The important signals:
- Error code
AADSTS70011: Invalid scope — your app is requesting a permission you didn’t configure - Error code
AADSTS700016: App not found — wrong tenant ID or client ID - Error code
AADSTS50105: App assignment missing (see above) - Error code
AADSTS65001: User hasn’t consented — needs admin consent for the permission
Use Conditional Access
If your Entra license includes it (P1 or P2), Conditional Access policies are the right place to enforce MFA for specific apps, block sign-ins from outside your country, or require compliant devices. Don’t re-implement this logic in your app — let Entra handle it.
Typical policy for a sensitive self-hosted tool:
- Target: your specific app
- Conditions: all locations except named trusted IPs
- Controls: require MFA
This applies the requirement at the IdP level before your app ever sees the user.
Debugging Auth Failures
When something breaks, these are the places to look — in order.
1. Entra Sign-in Logs: Always check here first. The error code is specific and usually tells you exactly what’s wrong. Don’t guess until you’ve read the logs.
2. The JWT: For OIDC, grab the id_token from your app’s debug logs or a proxy like Wireshark/Burp. Decode it at jwt.io. Check that the claims your app expects are actually present. Missing email? Not in the token config. Groups not there? Probably the 200-group limit issue.
3. The SAML Assertion: For SAML, a browser SAML tracer extension (Firefox has "SAML-tracer", Chrome has "SAML DevTools Extension") intercepts the POST and shows you the decoded XML. The assertion contains the NameID and all attributes. This is far faster than reading base64 from network logs.
4. The Clock: SAML assertions have a NotBefore and NotOnOrAfter. If your server’s clock is off by more than a few minutes, assertions will be rejected as expired or not-yet-valid. OIDC has this problem too with nbf/exp. Run NTP everywhere.
Gotcha: The "Redirect URI mismatch" error is the most common OIDC failure. Entra is strict — the redirect URI in the auth request must exactly match one registered in the App Registration. Case-sensitive, trailing-slash-sensitive. If your app sends
https://app.example.com/callbackbut you registeredhttps://app.example.com/callback/, it fails. Add both variants if needed.
Multi-App Setup: Don’t Create a New App Registration for Every Service
If you’re federating many apps into Entra, you’ll be tempted to create a separate App Registration per service. That’s fine for isolation, but for 20 apps it becomes a management nightmare.
An alternative: create a single "SSO Gateway" app registration and route all apps through a local auth proxy like OAuth2 Proxy. The proxy handles the Entra OIDC dance, sets a cookie or header, and forwards the authenticated identity downstream. Your apps consume simple headers (X-Auth-User, X-Auth-Email) instead of implementing OIDC themselves.
This is the right architecture for apps that don’t support OIDC/SAML natively, like internal tools built on basic auth or anything that just trusts a header.
Docker Compose fragment for OAuth2 Proxy:
services:
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:latest
command:
- --provider=oidc
- --oidc-issuer-url=https://login.microsoftonline.com/${ENTRA_TENANT_ID}/v2.0
- --client-id=${CLIENT_ID}
- --client-secret=${CLIENT_SECRET}
- --redirect-url=https://proxy.yourcompany.com/oauth2/callback
- --email-domain=yourcompany.com # restrict to org domain
- --upstream=http://your-backend:8080
- --cookie-secure=true
- --cookie-secret=${COOKIE_SECRET} # 32-byte random value
ports:
- "4180:4180"
What You’ve Actually Built
When this is working, your self-hosted apps are fully integrated into your org’s identity lifecycle. New employee onboarding? They can log into Grafana the same day their Entra account is created. Employee offboarding? Disable the Entra account, and every federated app loses access simultaneously — no hunting through 20 apps to revoke credentials.
That’s the real value here. It’s not just convenience for the user. It’s security hygiene that scales with your org without manual work.
The setup is maybe two hours for OIDC, three for SAML if your app’s docs are decent. The maintenance burden afterward is essentially zero — certificate rotation once every two years, the occasional conditional access policy update. That’s a good trade.