Skip to main content

JWT Middleware Pipeline

Middleware Execution Order

1. Request Logging Middleware

2. Exception Handler Middleware

3. CORS Middleware

4. Authentication Middleware ← JWT validation happens here

5. Authorization Middleware ← [Authorize] attribute enforced

6. Controller Endpoint

JWT Validation Steps

Step 1: Token Extraction
string authHeader = context.Request.Headers["Authorization"];
if (!authHeader.StartsWith("Bearer "))
    return 401 Unauthorized;

string token = authHeader.Substring(7); // Remove "Bearer " prefix
Step 2: Signature Validation
var tokenHandler = new JwtSecurityTokenHandler();
var validationParameters = new TokenValidationParameters {
    ValidateIssuerSigningKey = true,
    IssuerSigningKey = new SymmetricSecurityKey(
        Encoding.UTF8.GetBytes(Configuration["JWT_SECRET"])
    ),
    ValidateIssuer = true,
    ValidIssuer = $"{Configuration["SUPABASE_URL"]}/auth/v1",
    ValidateAudience = true,
    ValidAudience = "authenticated",
    ValidateLifetime = true,
    ClockSkew = TimeSpan.Zero
};

ClaimsPrincipal principal = tokenHandler.ValidateToken(
    token, 
    validationParameters, 
    out SecurityToken validatedToken
);
Validation Failures:
  • Invalid signature → SecurityTokenInvalidSignatureException → 401
  • Wrong issuer → SecurityTokenInvalidIssuerException → 401
  • Wrong audience → SecurityTokenInvalidAudienceException → 401
  • Expired token → SecurityTokenExpiredException → 401
Step 3: Claims Extraction
var userId = principal.FindFirst("sub")?.Value;
var email = principal.FindFirst("email")?.Value;
var fullName = principal.FindFirst("full_name")?.Value;

if (string.IsNullOrEmpty(userId))
    return 401 Unauthorized;
Step 4: Context Population
context.User = principal;
context.Items["UserId"] = userId;
context.Items["Email"] = email;
Controller accesses via:
var userId = User.FindFirst("sub")?.Value;
var email = User.FindFirst("email")?.Value;

User Synchronization

Automatic Sync Trigger

Every authenticated controller action calls:
[Authorize]
public async Task<IActionResult> Chat([FromBody] ChatRequest request)
{
    var userId = User.FindFirst("sub")?.Value;
    var email = User.FindFirst("email")?.Value;
    var fullName = User.FindFirst("full_name")?.Value;
    
    // User sync happens here (every request)
    await _userSyncService.EnsureUserExistsAsync(userId, email, fullName);
    
    // ... rest of logic
}

EnsureUserExistsAsync Implementation

public async Task EnsureUserExistsAsync(string userId, string email, string fullName)
{
    try {
        var user = new User {
            UserId = Guid.Parse(userId),
            Email = email ?? $"user_{userId}@placeholder.com",
            FullName = fullName ?? email?.Split('@')[0] ?? "User",
            Role = "user"
        };
        
        await _supabaseClient
            .From<User>()
            .Upsert(user);
            
    } catch (Exception ex) {
        _logger.LogWarning($"User sync failed for {userId}: {ex.Message}");
        // Don't throw - user sync failure shouldn't block request
    }
}
SQL Executed:
INSERT INTO users (user_id, email, full_name, role, created_at)
VALUES ($1, $2, $3, 'user', NOW())
ON CONFLICT (user_id) DO UPDATE
SET email = EXCLUDED.email,
    full_name = EXCLUDED.full_name,
    updated_at = NOW();

Sync Characteristics

Performance: ~5-15ms (indexed UPSERT) Idempotency: Safe to call multiple times Failure Handling: Logged as warning, request continues Timing: Synchronous (awaited before proceeding) Frequency: Every authenticated request

Supabase Integration

JWT Issuer Validation

Supabase issues JWTs with specific issuer format:
iss: "https://<PROJECT_REF>.supabase.co/auth/v1"
Backend validates:
ValidIssuer = $"{Configuration["SUPABASE_URL"]}/auth/v1"
Configuration:
SUPABASE_URL=https://abcdefgh.supabase.co
Validation ensures token came from correct Supabase project.

Service Role vs Anon Key

Client-Side (Supabase Anon Key):
const supabase = createClient(
  SUPABASE_URL,
  SUPABASE_ANON_KEY  // Limited permissions, RLS enforced
);
Server-Side (Supabase Service Role):
var supabase = new SupabaseClient(
  Configuration["SUPABASE_URL"],
  Configuration["SUPABASE_SERVICE_ROLE_KEY"]  // Full access, bypasses RLS
);
Why Service Role in Backend?
  • Full database access (no RLS restrictions)
  • Perform UPSERT operations
  • Read all tables regardless of RLS policies
  • Backend implements authorization in application code

Authorization vs Authentication

Authentication

Question: “Who are you?” Mechanism: JWT validation via middleware Result: User principal populated with claims Happens: Before controller method execution

Authorization

Question: “Are you allowed to do this?” Mechanism: Custom logic in controller Example:
var thread = await _threadsService.GetByIdAsync(threadId);
var userId = User.FindFirst("sub")?.Value;

if (thread.UserId != userId)
    return Forbid(); // 403 Forbidden
Happens: Inside controller method logic

[Authorize] Attribute

[Authorize]  // Requires authentication but not specific authorization
public async Task<IActionResult> GetThread(string id)
{
    var thread = await _threadsService.GetByIdAsync(id);
    
    // Custom authorization check
    if (thread.Visibility == "private") {
        var userId = User.FindFirst("sub")?.Value;
        if (thread.UserId != userId)
            return Forbid();
    }
    
    return Ok(thread);
}

[AllowAnonymous] Attribute

[AllowAnonymous]  // No JWT required
public IActionResult Health()
{
    return Ok(new { status = "healthy" });
}
Public Endpoints:
  • /health
  • /api/health
  • /api/ping
  • /api/settings/feature-flag/{key}
  • /api/speech/generate

Token Lifecycle

Token Acquisition (Client)

const { data } = await supabase.auth.signInWithPassword({
  email: 'user@example.com',
  password: 'password'
});

const jwt = data.session.access_token;        // 1-hour expiry
const refreshToken = data.session.refresh_token; // 30-day expiry

Token Usage

fetch('/api/arena/chat', {
  headers: {
    'Authorization': `Bearer ${jwt}`
  }
});

Token Expiration

Access Token: Expires after 1 hour Detection:
const payload = JSON.parse(atob(jwt.split('.')[1]));
if (payload.exp * 1000 < Date.now()) {
  // Token expired
}

Token Refresh

const { data, error } = await supabase.auth.refreshSession({
  refresh_token: refreshToken
});

if (!error) {
  const newJwt = data.session.access_token;
  // Use newJwt for subsequent requests
}
Automatic Refresh: Supabase client libraries auto-refresh before expiration

Security Considerations

JWT Secret Protection

Environment Variable:
JWT_SECRET=your_supabase_jwt_secret_here
Acquisition: From Supabase project settings → API → JWT Settings Critical: Never commit to version control Development: If missing, signature validation may be skipped (logged warning) Production: MUST be set or all requests will fail 401

HTTPS Requirement

JWTs transmitted in Authorization header (plaintext over HTTP). Development: HTTP acceptable (localhost) Production: HTTPS mandatory Why: Bearer tokens are susceptible to man-in-the-middle attacks over HTTP

Token Revocation

Limitation: JWTs cannot be revoked before expiration Mitigation: Short expiry (1 hour) Workaround: Track revoked tokens in database (not currently implemented)

Supabase Auth Events

Not Used: DualMind doesn’t listen to Supabase auth webhooks User Deletion: Deleting user in Supabase doesn’t cascade to DualMind database Implication: Orphaned user records possible if user deleted from Supabase Auth Future: Implement webhook listener for user.deleted event

Development vs Production

Development Mode

JWT_SECRET: May be missing (validation skipped with warning) CORS: Allows all origins HTTPS: Not enforced Logging: Verbose request/response logging

Production Mode

JWT_SECRET: MUST be set (validation enforced) CORS: Configured allowed origins only HTTPS: Enforced at infrastructure level Logging: Error-level only (no verbose logging)

Troubleshooting

401 Unauthorized

Causes:
  1. Missing Authorization header
  2. Invalid JWT signature (wrong JWT_SECRET)
  3. Expired token (exp < now)
  4. Wrong issuer (Supabase URL mismatch)
  5. Wrong audience (aud != "authenticated")
Debug:
// Decode JWT to inspect claims (don't validate signature)
const payload = JSON.parse(atob(jwt.split('.')[1]));
console.log(payload);

403 Forbidden

Causes:
  1. Valid JWT but accessing resource owned by different user
  2. Private thread accessed by non-owner
Debug: Check thread.user_id vs JWT sub claim

User Sync Failures

Symptom: Warnings in logs but requests succeed Causes:
  1. Database connectivity issue
  2. Invalid user_id format (not UUID)
  3. Supabase service role key incorrect
Impact: Minimal (user record may be stale but auth works)

Next Steps

Request Lifecycle

Where JWT validation fits in execution flow

System Invariants

Authentication-related invariants