Back to Blog
SecurityAuthenticationAzure ADJWT.NET

Secure Authentication in Enterprise Apps: Azure AD, JWT, and Session Cookies

Umut Korkmaz2025-10-1011 min

Authentication in enterprise applications is never as simple as "just use JWT." In the real world, you deal with multiple identity providers, legacy systems that only understand cookies, network zones with different security requirements, and compliance teams that have opinions about everything. In the internal platform, I built a hybrid authentication system that supports multiple auth mechanisms, and I want to share exactly how it works.

The Problem

the internal platform serves users in a few distinct scenarios:

  1. Internal network users on domain-joined Windows machines who expect seamless single sign-on via Windows Authentication (Negotiate/NTLM)
  2. External users and modern clients authenticating via Azure AD with JWT bearer tokens
  3. Legacy integrations that still rely on session cookies from the old ASP.NET application

Each mechanism has different trust levels, different token lifetimes, and different security implications. The challenge is unifying them into a single authorization model so that the rest of the application does not care how the user authenticated.

The Smart Auth Handler

The core of the solution is a custom authentication handler that inspects each incoming request and delegates to the appropriate auth mechanism:

csharp
public class SmartAuthSchemeOptions : AuthenticationSchemeOptions
{
    public string JwtScheme { get; set; } = "Bearer";
    public string WindowsScheme { get; set; } = "Negotiate";
    public string SessionScheme { get; set; } = "Session";
}

public class SmartAuthHandler : AuthenticationHandler<SmartAuthSchemeOptions>
{
    private readonly ISessionStore _sessionStore;

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // Priority 1: JWT Bearer (most secure, preferred)
        var authHeader = Request.Headers.Authorization.FirstOrDefault();
        if (authHeader?.StartsWith("Bearer ") == true)
        {
            var result = await Context.AuthenticateAsync(Options.JwtScheme);
            if (result.Succeeded)
                return EnrichWithClaims(result, "AzureAD");
        }

        // Priority 2: Windows Authentication
        var windowsResult = await Context.AuthenticateAsync(Options.WindowsScheme);
        if (windowsResult.Succeeded)
            return await EnrichWindowsIdentity(windowsResult);

        // Priority 3: Session cookie (legacy)
        if (Request.Cookies.TryGetValue(".App.Session", out var sessionId))
        {
            var session = await _sessionStore.GetAsync(sessionId);
            if (session is not null && !session.IsExpired)
                return CreateSessionResult(session);
        }

        return AuthenticateResult.NoResult();
    }
}

The order matters. JWT is checked first because it is the most explicitly secure — the client made a deliberate choice to include a bearer token. Windows Auth is second because it is implicit (the browser sends credentials automatically). Session cookies are last as the legacy fallback.

Layered Security Middleware

Authentication is just the first layer. the internal platform uses layered security middleware that executes on every request. Here are the most critical pieces:

csharp
// Program.cs - Middleware pipeline (order is critical)
app.UseMiddleware<RequestIdMiddleware>();
app.UseMiddleware<SecurityHeadersMiddleware>();
app.UseMiddleware<IpWhitelistMiddleware>();
app.UseMiddleware<RateLimitingMiddleware>();
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<PiiSanitizationMiddleware>();
app.UseAuthentication();
app.UseMiddleware<SessionValidationMiddleware>();
app.UseMiddleware<CsrfProtectionMiddleware>();
app.UseMiddleware<AuditLoggingMiddleware>();
app.UseAuthorization();
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseMiddleware<PermissionEnforcementMiddleware>();

PII Sanitization in Logs

The PiiSanitizationMiddleware works with Serilog to ensure that personally identifiable information never ends up in our logs. This is a compliance requirement for high-compliance applications:

csharp
public class PiiSanitizationEnricher : ILogEventEnricher
{
    private static readonly Regex TcKimlikPattern =
        new(@"\b[1-9]\d{10}\b", RegexOptions.Compiled);

    private static readonly Regex EmailPattern =
        new(@"[\w.-]+@[\w.-]+\.\w+", RegexOptions.Compiled);

    public void Enrich(LogEvent logEvent, ILogEventPropertyFactory factory)
    {
        var message = logEvent.RenderMessage();

        message = TcKimlikPattern.Replace(message, "[REDACTED-ID]");
        message = EmailPattern.Replace(message, "[REDACTED-EMAIL]");

        // Replace the rendered message
        logEvent.AddOrUpdateProperty(
            factory.CreateProperty("SanitizedMessage", message));
    }
}

CSRF Protection for Cookie-Based Auth

When a user is authenticated via cookies (session or httpOnly auth cookies), CSRF protection is mandatory. The middleware validates the anti-forgery token on every state-changing request:

csharp
public class CsrfProtectionMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        // Only enforce CSRF for cookie-authenticated requests
        if (!IsAuthenticatedViaCookie(context))
        {
            await next(context);
            return;
        }

        if (IsStateChangingMethod(context.Request.Method))
        {
            var csrfToken = context.Request.Headers["X-CSRF-TOKEN"]
                .FirstOrDefault();
            var cookieToken = context.Request.Cookies[".App.CSRF"];

            if (string.IsNullOrEmpty(csrfToken) ||
                !CryptoHelper.ValidateTokenPair(csrfToken, cookieToken))
            {
                context.Response.StatusCode = 403;
                await context.Response.WriteAsJsonAsync(
                    new { error = "CSRF validation failed" });
                return;
            }
        }

        await next(context);
    }
}

Azure AD JWT Configuration

The JWT validation configuration is strict — no room for misconfiguration in a high-compliance app:

csharp
services.AddAuthentication()
    .AddJwtBearer("Bearer", options =>
    {
        options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
        options.Audience = configuration["AzureAd:ClientId"];

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = $"https://login.microsoftonline.com/{tenantId}/v2.0",
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(2), // Reduced from default 5 min
            RequireSignedTokens = true,
            RequireExpirationTime = true,
        };

        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = async context =>
            {
                // Enrich claims with app-specific roles from database
                var userService = context.HttpContext.RequestServices
                    .GetRequiredService<IUserService>();
                var roles = await userService
                    .GetRolesAsync(context.Principal!.GetObjectId());

                var identity = context.Principal!.Identity as ClaimsIdentity;
                foreach (var role in roles)
                    identity!.AddClaim(new Claim(ClaimTypes.Role, role));
            },
        };
    });

The OnTokenValidated event is where we bridge Azure AD groups with our application-specific permissions. Azure AD tells us who the user is; our database tells us what they can do.

The Frontend Side: httpOnly Cookies with CSRF

On the React frontend, authentication uses httpOnly cookies rather than storing tokens in localStorage. This eliminates an entire class of XSS-based token theft:

typescript
// API client setup
const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true, // Send cookies with every request
});

// CSRF token interceptor
apiClient.interceptors.request.use((config) => {
  if (['post', 'put', 'delete', 'patch'].includes(config.method ?? '')) {
    const csrfToken = getCsrfTokenFromCookie();
    if (csrfToken) {
      config.headers['X-CSRF-TOKEN'] = csrfToken;
    }
  }
  return config;
});

The CSRF token is stored in a regular (non-httpOnly) cookie so JavaScript can read it, while the actual session token is in an httpOnly cookie that JavaScript cannot access. The server validates that both cookies are present and correlate with each other.

Unified Claims Principal

Regardless of how a user authenticates, the application sees a unified ClaimsPrincipal with the same set of claims:

csharp
private AuthenticateResult EnrichWithClaims(
    AuthenticateResult original, string authMethod)
{
    var identity = original.Principal!.Identity as ClaimsIdentity;

    identity!.AddClaim(new Claim("auth_method", authMethod));
    identity.AddClaim(new Claim("auth_time",
        DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()));

    return AuthenticateResult.Success(
        new AuthenticationTicket(original.Principal!, original.Properties,
            Scheme.Name));
}

Controllers and handlers never need to know whether the user came through Azure AD, Windows Auth, or a session cookie. They just check User.IsInRole("DocumentApprover") and it works.

Lessons Learned

  1. One auth mechanism is a luxury. Enterprise apps almost always need multiple. Design for it from the start.
  2. httpOnly cookies beat localStorage for token storage in browser apps. The XSS attack surface shrinks dramatically.
  3. CSRF protection is only needed for cookie auth. Bearer token requests are inherently CSRF-safe because the token must be explicitly added.
  4. Log sanitization is a compliance requirement, not a nice-to-have. Build it into your logging pipeline, not as an afterthought.
  5. Reduce JWT clock skew. The default 5-minute skew is too generous. Two minutes is enough to handle clock drift without creating a security window.