how-to
Runbook: Azure App Registration for Microsoft/Outlook Inbox Access
Written by the build · 2 September 2026 · owner @farhan-s · reviewed 2026-09-01
Runbook: Azure App Registration for Microsoft/Outlook Inbox Access
Goal: Register an app in Microsoft Entra ID so Bundul can read a user's Outlook / Microsoft 365 / Outlook.com inbox (delegated, read-only) for bill detection — matching our existing Graph code in src/email-parser/auth/microsoft/.
Time: ~20 minutes. Cost: Free (app registration needs no paid Azure subscription).
Our code already expects these values — the registration just has to produce them:
- Endpoint:
https://login.microsoftonline.com/common/...(multi-tenant + personal accounts) - Scopes:
offline_access User.Read Mail.Read - Env:
MICROSOFT_CLIENT_ID,MICROSOFT_CLIENT_SECRET,MICROSOFT_CALLBACK_URL - Prod callback:
https://prodapi.bundul.io/api/auth/microsoft/callback
Prerequisites
- A Microsoft account (work or personal) that can sign in to the Microsoft Entra admin center or Azure Portal.
- No subscription or billing required for the registration itself.
Step 1 — Create the App Registration
- Go to https://entra.microsoft.com → Identity → Applications → App registrations → New registration. (Same thing lives in Azure Portal under Microsoft Entra ID → App registrations.)
- Name:
Bundul Mail Parser— this text appears on the user's consent screen, so keep it clean/branded. - Supported account types: select
"Accounts in any organizational directory (Any Microsoft Entra ID tenant – Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)."
⚠️ This exact option is mandatory to support consumer Outlook.com / Hotmail / Live inboxes and work/school accounts. It's what makes the
/common/endpoint in our code valid. - Redirect URI: platform = Web, value =
https://prodapi.bundul.io/api/auth/microsoft/callback. (More added in Step 3.) - Click Register.
Step 2 — Copy the Client ID
On the app's Overview page, copy Application (client) ID → this becomes MICROSOFT_CLIENT_ID.
(You do not need the Directory/tenant ID because we authenticate against /common.)
Step 3 — Register all redirect URIs
Go to Authentication. Under the Web platform, add every environment's callback:
https://prodapi.bundul.io/api/auth/microsoft/callback(prod)https://<staging-host>/api/auth/microsoft/callback(staging, if any)http://localhost:3030/api/auth/microsoft/callback(local dev — http is allowed only for localhost)
Mobile app (MSAL / serverAuthCode flow): if the app performs the OAuth and sends us a serverAuthCode, click + Add a platform → Mobile and desktop applications and register the app's redirect URI (e.g. a custom scheme like msauth.io.bundul://auth or the platform default). The redirect the app uses during /authorize must be listed here or Microsoft rejects the flow. Coordinate the exact value with the mobile team.
Other Authentication settings:
- Leave Implicit grant (Access tokens / ID tokens) unchecked — we use the auth-code flow.
- Allow public client flows: No (unless the app uses device-code).
- Click Save.
Step 4 — Create a Client Secret
- Go to Certificates & secrets → Client secrets → New client secret.
- Description:
bundul-backend; Expiry: pick ≤ 24 months (Azure's max). Note the expiry date. - Click Add, then immediately copy the secret Value (the long string, not the "Secret ID"). It is shown only once.
- This becomes
MICROSOFT_CLIENT_SECRET. - Set a calendar reminder to rotate it ~2 weeks before expiry — when it lapses, every token refresh fails with a 500 and all Microsoft inboxes stop syncing. (Prod-grade alternative: upload a certificate instead of a secret.)
Step 5 — Add Graph API Permissions (Delegated)
- Go to API permissions → Add a permission → Microsoft Graph → Delegated permissions.
- Add these:
User.Read(usually present by default)Mail.Readoffline_access- (optional OIDC:
openid,email,profile)
- These are all user-consentable, so you do not need to click "Grant admin consent" for consumer or typical work accounts.
- Exception: some corporate tenants enforce an admin-consent policy — in that case their IT admin approves it on first sign-in. Nothing you configure changes that.
Mail.Readis read-only (parity with ourgmail.readonly). If we ever add sending, that's a separateMail.Sendpermission + re-consent.
Step 6 — (Recommended) Branding & Publisher Verification
Go to Branding & properties:
- Set logo, Publisher display name, Terms of service URL, Privacy statement URL.
- Complete Publisher verification to remove the "unverified app" warning on the consent screen (needs a verified Microsoft Partner/MPN account). Strongly recommended before consumer launch — it's the difference between a trusted consent screen and a scary one.
Step 7 — Wire the values into our config
| Azure output | Env var | Notes |
|---|---|---|
| Application (client) ID | MICROSOFT_CLIENT_ID |
Overview page |
| Client secret Value | MICROSOFT_CLIENT_SECRET |
Store in secrets manager, not committed .env |
| Chosen callback per env | MICROSOFT_CALLBACK_URL |
e.g. https://prodapi.bundul.io/api/auth/microsoft/callback |
Document all three in .env.example. Our code (microsoft-auth.service.ts, microsoft.strategy.ts) reads exactly these keys and falls back to the prod callback if MICROSOFT_CALLBACK_URL is unset.
Step 8 — Smoke-test the consent + token flow
- Paste this authorize URL in a browser (replace
{CLIENT_ID}), sign in, and approve:https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id={CLIENT_ID}&response_type=code&redirect_uri=https%3A%2F%2Fprodapi.bundul.io%2Fapi%2Fauth%2Fmicrosoft%2Fcallback&response_mode=query&scope=offline_access%20User.Read%20Mail.Read&prompt=consent - You should land on the redirect URI with
?code=.... That confirms client ID, redirect URI, account types, and scopes are all correct. - Then run the real flow end-to-end via the app's
connectMicrosoftForMailParsermutation and confirm a token row lands inUserEmailMailAccesswithprovider: 'microsoft'. - Test twice: once with a consumer
outlook.comaccount, once with a Microsoft 365 work account — consent and refresh-token behavior differ between them.
Gotchas checklist
- Account type is multitenant + personal (not single-tenant) — or consumer inboxes fail.
- Every redirect URI the client uses (web + mobile + local) is registered, exact-match including trailing path.
- Secret Value captured (not Secret ID); expiry reminder set.
- Delegated
Mail.Read+offline_accesspresent (offline_accessis what gives us the refresh token). - Secret lives in secrets manager, not the repo.
- Personal-account refresh tokens can expire after ~90 days of inactivity → app needs a "reconnect" prompt when refresh fails (backend change, tracked separately).
- Publisher verification done before consumer launch to avoid the "unverified" consent warning.