Add Microsoft Sign‑In to Next.js with Azure Entra + OIDC (PKCE)

Rohit Ramachandran avatarRohit Ramachandran
Microsoft login flow diagram for Next.js with OAuth 2.0 and OIDC

Add Microsoft (Outlook) Sign‑In to Next.js with Azure Entra + OIDC (PKCE, JWT, Drizzle)

This guide adds “Continue with Microsoft” to your Next.js App Router app using Azure Entra (formerly Azure AD) with OAuth 2.0 + OpenID Connect.

We’ll support both personal Outlook and work/school accounts by using tenant=common, and we’ll keep everything secure with PKCE + state + nonce and the same HttpOnly JWT cookie you use for email/password. Along the way, I’ll explain the why and the gotchas so you can own this stack.


The Story: Why Microsoft too?

After shipping Google, I kept getting “Can I sign in with my work account?” The answer is yes—with Azure Entra, we can let both personal and enterprise users in. The trick is to keep the same small surface area: two routes, one link table, and a session cookie you already trust.


1) Azure App Registration

  1. Azure Portal → Microsoft Entra ID → App registrations → New registration.
  2. Name your app.
  3. Supported account types: select “Accounts in any organizational directory and personal Microsoft accounts”.
  4. Redirect URI (Web): http://localhost:3000/api/auth/callback/microsoft.

After register:

  • Authentication → Web → ensure the redirect is present and check “ID tokens”.
  • Token configuration → Add optional claim (ID): email, given_name, family_name.
  • API permissions → Microsoft Graph → Delegated: openid, profile, email (optional offline_access).
  • Certificates & secrets → Client secrets → New client secret → copy the Secret Value.

2) Environment variables

In frontend/.env.local:

PUBLIC_APP_URL=http://localhost:3000
AUTH_SECRET=<strong random 32+ bytes>
MS_CLIENT_ID=<Application (client) ID>
MS_CLIENT_SECRET=<Client secret value>
MS_TENANT=common

Restart the dev server to pick up env.


3) Database linking (shared)

Use the same oauth_accounts table as Google to link provider accounts to local users (see the Google post for the definition). Run migrations if you haven’t already.

cd frontend
npm run db:generate
npm run db:migrate

4) Routes: start and callback for Microsoft

Start route builds the authorize URL with PKCE + state + nonce and stores short‑lived cookies:

import { NextResponse } from 'next/server';

export const runtime = 'nodejs';
// base64url, sha256, rand helpers …

export async function GET() {
  const clientId = process.env.MS_CLIENT_ID;
  const tenant = process.env.MS_TENANT || 'common';
  const base = process.env.NEXT_PUBLIC_BASE_URL || process.env.PUBLIC_APP_URL || '';
  if (!clientId || !base) return NextResponse.json({ error: 'Microsoft OAuth not configured' }, { status: 500 });

  const redirectUri = `${base.replace(/\/$/, '')}/api/auth/callback/microsoft`;
  const state = rand(24); const nonce = rand(24); const verifier = rand(48);
  const challenge = await sha256(verifier);

  const authUrl = new URL(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`);
  authUrl.searchParams.set('client_id', clientId);
  authUrl.searchParams.set('redirect_uri', redirectUri);
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('response_mode', 'query');
  authUrl.searchParams.set('scope', 'openid profile email');
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('nonce', nonce);
  authUrl.searchParams.set('code_challenge', challenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  const res = NextResponse.redirect(authUrl.toString());
  const isHttps = base.startsWith('https://');
  const opts = { httpOnly: true, sameSite: 'lax' as const, secure: isHttps, path: '/', maxAge: 600 };
  res.cookies.set('ms_state', state, opts);
  res.cookies.set('ms_nonce', nonce, opts);
  res.cookies.set('ms_verifier', verifier, opts);
  return res;
}

Callback route exchanges the code, verifies the ID token via Microsoft’s JWKS, and links/creates the user:

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db/drizzle';
import { oauthAccounts, teamMembers, teams, users } from '@/lib/db/schema';
import { and, eq } from 'drizzle-orm';
import { setSession, hashPassword } from '@/lib/auth/session';
import { logActivity } from '@/lib/db/queries';
import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';

export const runtime = 'nodejs';
function getJwksUri(t: string) { return new URL(`https://login.microsoftonline.com/${t}/discovery/v2.0/keys`); }

export async function GET(request: NextRequest) {
  const base = process.env.NEXT_PUBLIC_BASE_URL || process.env.PUBLIC_APP_URL || '';
  const clientId = process.env.MS_CLIENT_ID;
  const clientSecret = process.env.MS_CLIENT_SECRET;
  const tenant = process.env.MS_TENANT || 'common';
  // 1) Validate config, read code+state, verify cookies (state/nonce/verifier)
  // 2) POST token exchange with code_verifier
  // 3) Verify ID token signature + audience; check nonce
  // 4) Link or create user; set session; clear temp cookies; redirect
  /* …full implementation in repo… */
}

5) UI buttons on sign‑in/sign‑up

<a href="/api/auth/oauth/microsoft/start" className="btn">Continue with Microsoft</a>
<a href="/api/auth/oauth/microsoft/start" className="btn">Continue with Microsoft</a>

Optional icon at public/microsoft.svg.


6) Linking behavior and sessions

Same flow as Google:

  • If provider account linked → sign in.
  • If same email user exists → link to that user.
  • Else create user + team, link, set session, redirect to /account.

7) Testing and common pitfalls

Testing:

  1. Restart dev after setting env.
  2. /sign-in → “Continue with Microsoft”.
  3. Sign in with a personal or work/school account.
  4. Land on /account; verify /api/user.

Pitfalls:

  • Ensure Authentication → Web → “ID tokens” is checked; otherwise you won’t get an ID token.
  • Redirect URI must match exactly.
  • Missing email claim? Add optional ID claims under Token configuration.
  • Wrong tenant? Use MS_TENANT=common to allow both account types.

ELI5: tenant and JWKS

  • tenant=common tells Microsoft “accept both personal and work/school accounts”. If you only want consumer Outlook accounts, use consumers; for a single organization, use its tenant ID.
  • JWKS is Microsoft’s public key set. We verify the ID token’s signature against it. If the signature, audience (your client ID), or nonce doesn’t match, we abort.

8) Production

  • Add your prod redirect URI in Azure and keep MS_TENANT=common unless you need to restrict.
  • Set prod env (MS_CLIENT_ID, MS_CLIENT_SECRET, PUBLIC_APP_URL=https://yourdomain.com).
  • Keep cookies HttpOnly and Secure in prod; rotate secrets on schedule.

Enterprise notes:

  • Some orgs enforce conditional access or admin consent. Your basic OIDC scopes (openid, profile, email) usually work, but be ready to handle “admin consent required” if you add Graph APIs later.

Final checklist

  • [ ] Azure App Registration (common tenant) with Web redirect(s).
  • [ ] ID token enabled; optional ID claims (email, given/family name) set.
  • [ ] Microsoft Graph permissions: openid, profile, email.
  • [ ] .env.local with MS credentials and MS_TENANT=common.
  • [ ] Start + callback routes added and deployed.
  • [ ] Buttons on sign‑in/sign‑up.
  • [ ] Tested end‑to‑end in dev; logs are clean.

FAQ

Q: Why am I not getting an email claim?
A: Add optional ID claims (email, given_name, family_name) under Token configuration. For some tenants, you may only get preferred_username—we handle that as a fallback.

Q: Can I restrict to personal accounts only?
A: Set MS_TENANT=consumers and adjust the App Registration to allow personal accounts only.

Q: Do I need refresh tokens?
A: Not for simple sign‑in. Add offline_access only if you’ll call Microsoft Graph on the user’s behalf.