Applied Module 12 · AI-Accelerated Government Development

Government Auth in Next.js

What you'll learn

~30 min
  • Configure @azure/msal-node for Azure AD authentication against a government tenant
  • Implement next-auth v5 with JWT strategy and 8-hour session max
  • Protect routes with middleware.ts and validate sessions in API routes

Auth in a government stack

Authentication on the DS platform is not optional, configurable, or “something we’ll add later.” Every route is protected. Every session has a ceiling. Every token refresh follows a deterministic path. This lesson builds that layer.

You will generate three files: the MSAL configuration, the next-auth route handler, and the middleware that enforces protection before any page renders.

💬Why JWT, not database sessions

Database sessions require a round-trip to Azure SQL on every request. In a government deployment, that adds latency behind a Managed Identity token acquisition chain. JWT sessions are self-contained — the middleware can validate them at the edge without touching the database. The tradeoff is that revocation requires a token blocklist, but for an 8-hour max session with no remember-me, the window is acceptable.


The auth architecture

Browser → middleware.ts (JWT validation, route check)
├── Valid token → render page
├── Expired token → redirect to /api/auth/signin
└── No token → redirect to /api/auth/signin
/api/auth/[...nextauth]/route.ts
├── Azure AD provider via @azure/msal-node
├── JWT callback → attach role claim, enforce 8h max
└── Session callback → expose role to client

Three files. No auth database. No session table. The JWT carries the role claim, and the middleware reads it before the request reaches your page component.


Government authorityHost: the critical difference

The critical difference between Azure Commercial and Azure Government auth is the authority host. Azure AD in government uses login.microsoftonline.us, not login.microsoftonline.com. Get this wrong and the login page loads but the token exchange fails with AADSTS90002: Tenant not found.

src/lib/auth/msal-config.ts
import { ConfidentialClientApplication } from '@azure/msal-node';
export const msalConfig = {
auth: {
clientId: process.env.AZURE_AD_CLIENT_ID!,
// Government authority — login.microsoftonline.us
authority: `https://login.microsoftonline.us/${process.env.AZURE_AD_TENANT_ID}`,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
},
};
export const msalInstance = new ConfidentialClientApplication(msalConfig);
// Scopes for government Graph API
export const GOV_GRAPH_SCOPES = ['https://graph.microsoft.us/.default'];
Never use login.microsoftonline.com

If you copy auth examples from the Next.js docs, Microsoft Learn, or Stack Overflow, they will use login.microsoftonline.com. Every single one must be changed to login.microsoftonline.us for government tenants. This is not a configurable preference — it is a hard infrastructure boundary.


The prompt

Open your AI CLI tool at the project root and paste this:

Generate the authentication layer for our Next.js 15.5 platform.
Requirements:
1. MSAL CONFIG (src/lib/auth/msal-config.ts)
- ConfidentialClientApplication from @azure/msal-node
- Authority: https://login.microsoftonline.us/{TENANT_ID} (Azure Gov)
- Client ID, tenant ID, client secret from environment variables
- Export GOV_GRAPH_SCOPES for government Graph API
2. NEXT-AUTH ROUTE (src/app/api/auth/[...nextauth]/route.ts)
- next-auth v5 with Azure AD provider
- JWT strategy (not database)
- JWT callback: attach user role from profile claims, set maxAge 28800 (8 hours)
- Session callback: expose role to client-side session
- Use government endpoints:
- authorization: https://login.microsoftonline.us/{TENANT_ID}/oauth2/v2.0/authorize
- token: https://login.microsoftonline.us/{TENANT_ID}/oauth2/v2.0/token
- userinfo: https://graph.microsoft.us/oidc/userinfo
- Sign-in page redirect to /auth/signin (custom page)
3. MIDDLEWARE (src/middleware.ts)
- Export middleware function and config with matcher
- Check for valid next-auth session token
- Allow: /auth/*, /api/auth/*, /_next/*, /favicon.ico
- Redirect unauthenticated requests to /auth/signin
- Read role from JWT for downstream RBAC (lesson 4)
- Add x-user-role header to request for API routes
4. AUTH HELPER (src/lib/auth/session.ts)
- getServerSession() wrapper that returns typed session with role
- requireSession() that throws 401 if no session
- isSessionExpired() check against 8-hour window
Do NOT use database sessions. Do NOT use *.azure.com endpoints.
All auth endpoints must use *.microsoftonline.us (government).
TypeScript strict mode. No any types.

What you get

The AI generates four files that form a complete auth layer. Here is the interaction:

Claude Code — scaffolding the auth layer
/home/user $ Generate the authentication layer for our Next.js 15.5 platform. [full prompt above]
I'll create the four authentication files for your government Next.js platform. Created src/lib/auth/msal-config.ts - ConfidentialClientApplication with login.microsoftonline.us authority - GOV_GRAPH_SCOPES for government Graph API Created src/app/api/auth/[...nextauth]/route.ts - Azure AD provider with government OAuth endpoints - JWT strategy with 28800s (8-hour) maxAge - JWT callback attaches role claim from Azure AD groups - Session callback exposes role to client Created src/middleware.ts - Validates next-auth session token - Allows public paths: /auth/*, /api/auth/*, /_next/* - Reads role from JWT, sets x-user-role header - Redirects unauthenticated users to /auth/signin Created src/lib/auth/session.ts - getServerSession() returns typed { user, role, expiresAt } - requireSession() throws 401 SafeError if unauthenticated - isSessionExpired() checks 8-hour window from iat claim All endpoints use login.microsoftonline.us and graph.microsoft.us.
/home/user $ Show me the JWT callback logic. How does it enforce the 8-hour max?
Here's the JWT callback from the next-auth config: ```typescript callbacks: { async jwt({ token, account, profile }) { if (account && profile) { // Initial sign-in: set the issuance timestamp token.iat = Math.floor(Date.now() / 1000); token.role = resolveRoleFromClaims(profile); } // Enforce 8-hour absolute maximum (NIST AC-12) const iat = token.iat as number; const now = Math.floor(Date.now() / 1000); const MAX_SESSION_SECONDS = 28800; // 8 hours if (now - iat > MAX_SESSION_SECONDS) { // Force re-authentication return { ...token, expired: true }; } return token; }, } ``` The `iat` (issued-at) timestamp is set once at initial sign-in. Every subsequent request checks elapsed time against the 28,800-second cap. When exceeded, the token is marked expired and the middleware redirects to sign-in. This is an absolute timeout — no amount of activity extends it past 8 hours.
/home/user $

8-hour session max: NIST AC-12

NIST SP 800-53 control AC-12 requires automatic session termination after a defined period. The DS platform sets this at 8 hours (28,800 seconds). This is an absolute timeout, not an idle timeout — active use does not extend it.

The enforcement happens in two places:

  1. JWT callback — checks iat (issued-at) against current time on every token refresh. If elapsed time exceeds 28,800 seconds, the token is marked expired.
  2. Middleware — reads the expired flag and redirects to /auth/signin.
// In the JWT callback
const MAX_SESSION_SECONDS = 28800; // 8 hours
const elapsed = Math.floor(Date.now() / 1000) - (token.iat as number);
if (elapsed > MAX_SESSION_SECONDS) {
return { ...token, expired: true };
}
Why 8 hours, not 12 or 24

Eight hours maps to a standard government work shift. A session that outlasts the shift means an unattended workstation with an active session — a finding on any security assessment. Some agencies use shorter windows (4 hours for CJIS-adjacent systems). The platform defaults to 8; your security team may tighten it.


Token refresh logic

next-auth v5 handles OAuth token refresh automatically, but the 8-hour absolute cap overrides the refresh. The flow:

  1. User signs in. iat is set in the JWT.
  2. Access token expires (typically 1 hour). next-auth uses the refresh token to get a new access token.
  3. Each refresh, the JWT callback checks now - iat. If under 28,800 seconds, the refresh proceeds.
  4. At 28,800 seconds, the callback marks the token expired regardless of the refresh token’s validity.
  5. Middleware sees the expired flag and redirects to sign-in.

The refresh token may still be valid for days. That does not matter. The 8-hour window is measured from initial authentication, not from the last token refresh.


Route protection with middleware.ts

The middleware runs before every matched route. It is the first line of defense — no page component, no API route handler, and no server action runs without passing through it.

// src/middleware.ts — key sections
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
const PUBLIC_PATHS = ['/auth', '/api/auth', '/_next', '/favicon.ico'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
if (!token || token.expired) {
const signInUrl = new URL('/auth/signin', request.url);
signInUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(signInUrl);
}
// Forward role to API routes via request header
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-role', token.role as string);
return NextResponse.next({
request: { headers: requestHeaders },
});
}
No database access in middleware

Next.js middleware runs in the Edge Runtime. The mssql package (used for Azure SQL) requires Node.js APIs that do not exist in Edge. If you need database lookups for auth decisions, do them in API routes or Server Components, not middleware. The middleware can only read what is already in the JWT.


Session validation in API routes

API routes run in the Node.js runtime, not Edge. They can access the database, but they should still validate the session first:

src/lib/auth/session.ts
import { getServerSession as getNextAuthSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { SafeError } from '@/lib/response';
import type { Role } from '@/types/rbac';
interface TypedSession {
user: { name: string; email: string };
role: Role;
expiresAt: number;
}
export async function getServerSession(): Promise<TypedSession | null> {
const session = await getNextAuthSession(authOptions);
if (!session) return null;
return session as TypedSession;
}
export async function requireSession(): Promise<TypedSession> {
const session = await getServerSession();
if (!session) {
throw new SafeError('Authentication required', 401);
}
return session;
}

Every API route starts with const session = await requireSession();. No exceptions.


KNOWLEDGE CHECK

The DS platform enforces an 8-hour absolute session timeout per NIST AC-12. A user signed in at 8:00 AM and has been actively using the application all day. At 4:15 PM, they try to load a new page. What happens?


Common errors

ErrorCauseFix
AADSTS90002: Tenant not foundAuthority URL uses microsoftonline.com instead of .usChange authority to https://login.microsoftonline.us/{TENANT_ID}
AADSTS700016: Application not foundClient ID mismatch or app registration is in commercial, not govVerify the app registration exists in your Azure Government tenant
ERR_OSSL_UNSUPPORTED on token refreshNode.js OpenSSL config conflicts with FIPS modeSet NODE_OPTIONS=--openssl-legacy-provider in your environment
Role is undefined in sessionAzure AD group claims not configured in app registrationAdd groupMembershipClaims: "SecurityGroup" to the app manifest
Middleware redirect loop/auth/signin is not in the PUBLIC_PATHS listAdd /auth to the public paths array in middleware.ts

Key takeaways

  • Government auth requires explicit endpoint overrides. login.microsoftonline.us for authority, graph.microsoft.us for Graph API. There is no automatic detection.
  • JWT over database sessions eliminates a database round-trip on every request and allows middleware-level validation in the Edge Runtime.
  • 8-hour absolute timeout is non-negotiable. The iat claim is set once at sign-in and never reset. Activity does not extend the window.
  • Middleware protects routes, API routes validate sessions. The middleware redirects unauthenticated users. API routes call requireSession() to get the typed session object.
  • No database access in middleware. The Edge Runtime cannot run mssql. Auth decisions in middleware must rely solely on JWT claims.

What’s next

With authentication in place, the next lesson builds the visual shell: MUI 7 + Tailwind v4 with CSS layer ordering, CSP nonce injection, and a responsive layout. The auth layer you just built will protect every route that shell serves.