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.
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.
http://localhost:3000/api/auth/callback/microsoft.After register:
email, given_name, family_name.openid, profile, email (optional offline_access).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.
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
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… */
}
<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.
Same flow as Google:
/account.Testing:
/sign-in → “Continue with Microsoft”./account; verify /api/user.Pitfalls:
email claim? Add optional ID claims under Token configuration.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.MS_TENANT=common unless you need to restrict.MS_CLIENT_ID, MS_CLIENT_SECRET, PUBLIC_APP_URL=https://yourdomain.com).HttpOnly and Secure in prod; rotate secrets on schedule.Enterprise notes:
.env.local with MS credentials and MS_TENANT=common.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.