This guide provides detailed information about the secure implementation of refresh tokens in applications, focusing on SameSite cookie settings, cross-origin configuration, and proper HTTP client setup.

Frontend HTTP Client Configuration

Axios Configuration

// api-client.ts - Proper axios setup for refresh tokens
import axios, { AxiosInstance } from 'axios'

class ApiClient {
  private client: AxiosInstance

  constructor() {
    // Create axios instance with proper credentials configuration
    this.client = axios.create({
      baseURL: import.meta.env.VITE_API_URL || 'https://localhost:7177/api',
      timeout: 30000,
      headers: {
        'Content-Type': 'application/json',
      },
      withCredentials: true, // βœ… CRITICAL: Enables cookie sending
    })

    this.setupInterceptors()
  }

  private setupInterceptors() {
    // Request interceptor to add access token
    this.client.interceptors.request.use(
      (config) => {
        const token = TokenStorageManager.getAccessToken()
        if (token) {
          config.headers.Authorization = `Bearer ${token}`
        }
        return config
      },
      (error) => Promise.reject(error)
    )

    // Response interceptor for automatic token refresh
    this.client.interceptors.response.use(
      (response) => response,
      async (error) => {
        if (error.response?.status === 401) {
          try {
            // Use fetch for refresh to ensure consistent cookie behavior
            await this.refreshToken()
            // Retry original request
            return this.client.request(error.config)
          } catch (refreshError) {
            // Refresh failed, redirect to login
            AuthManager.logout()
            return Promise.reject(refreshError)
          }
        }
        return Promise.reject(error)
      }
    )
  }

  private async refreshToken(): Promise<void> {
    // Use fetch instead of axios for refresh to ensure cookies are sent
    const isHttps = window.location.protocol === 'https:'
    const refreshTokenFromStorage = TokenStorageManager.getRefreshToken()
    
    const response = await fetch(`${this.baseURL}/auth/refresh`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include', // βœ… CRITICAL: Include cookies
      body: JSON.stringify(
        refreshTokenFromStorage ? { refreshToken: refreshTokenFromStorage } : {}
      ),
    })

    if (!response.ok) {
      throw new Error('Token refresh failed')
    }

    const data = await response.json()
    TokenStorageManager.setAccessToken(data.accessToken)
    
    if (!isHttps && data.refreshToken) {
      TokenStorageManager.setRefreshToken(data.refreshToken)
    }
  }
}
Why withCredentials: true is Critical
  • Axios: withCredentials: true enables sending cookies with cross-origin requests
  • Fetch: credentials: 'include' serves the same purpose
  • Without this setting: Cookies won't be sent to different origins (ports/domains)
  • Result: Refresh token cookie won't reach the backend, causing authentication failures

Fetch API Configuration

// auth.service.ts - Alternative using fetch API
async refreshToken(): Promise<AuthResponse> {
  const refreshTokenFromStorage = localStorage.getItem('refreshToken')
  
  const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include', // βœ… CRITICAL: Sends cookies with request
    body: JSON.stringify(
      refreshTokenFromStorage ? { refreshToken: refreshTokenFromStorage } : {}
    ),
  })
  
  if (!response.ok) {
    throw new Error('Token refresh failed')
  }
  
  return response.json()
}

CORS Configuration

Backend CORS Setup

// Program.cs - CORS configuration
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
    {
        var frontendOptions = builder.Configuration
            .GetSection("Frontend")
            .Get<FrontendOptions>() ?? new FrontendOptions();
        
        policy.WithOrigins(frontendOptions.AllowedOrigins)  // βœ… Specific origins
              .AllowAnyHeader()                             // βœ… Allow all headers
              .AllowAnyMethod()                             // βœ… Allow all HTTP methods
              .AllowCredentials();                          // βœ… CRITICAL: Enable credentials
    });
});

// Apply CORS policy
app.UseCors("AllowFrontend");

Frontend Origins Configuration

// appsettings.json - Allowed frontend origins
{
  "Frontend": {
    "BaseUrl": "https://localhost:3000",
    "AllowedOrigins": [
      "http://localhost:3000",
      "https://localhost:3000",
      "http://localhost:5173",
      "https://localhost:5173"
    ]
  }
}

Security Considerations

1. HTTPS Requirements

// SameSite=None REQUIRES Secure=true (HTTPS)
var cookieOptions = new CookieOptions
{
    Secure = Request.IsHttps,  // βœ… Only secure in production
    SameSite = Request.IsHttps ? SameSiteMode.None : SameSiteMode.Lax
};

2. HttpOnly Protection

// Prevents XSS attacks
HttpOnly = true  // βœ… JavaScript cannot access the cookie

3. Token Cleanup Strategy

// Automatic cleanup during refresh
public async Task<LoginResponseDto> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
    // ... token validation ...
    
    // Save new refresh token (revokes old ones for user)
    await _jwtTokenService.SaveRefreshTokenAsync(user.Id, newRefreshToken);
    
    // Cleanup all expired tokens for all users
    var cleanedUpCount = await _jwtTokenService.CleanupExpiredTokensAsync(cancellationToken);
    
    return new LoginResponseDto
    {
        AccessToken = accessToken,
        RefreshToken = newRefreshToken
    };
}

Common Issues and Solutions

Issue 1: Cookies Not Sent Cross-Origin

Problem: Refresh token cookie stored on localhost:3000 but not sent to localhost:7177
// ❌ Wrong - cookies won't be sent
const response = await axios.post('/auth/refresh', data)

// βœ… Correct - cookies will be sent
const axiosInstance = axios.create({
  withCredentials: true  // Enable cookie sending
})

Issue 2: SameSite=Strict Blocking Cross-Origin

Problem: SameSite=Strict prevents cookies from being sent between different ports
// ❌ Too restrictive
SameSite = SameSiteMode.Strict

// βœ… Allows cross-origin
SameSite = isHttps ? SameSiteMode.None : SameSiteMode.Lax

Issue 3: Mixed HTTP Client Usage

Problem: Using different HTTP clients (axios vs fetch) with different credential settings
// βœ… Consistent approach - use same client with proper configuration
class ApiClient {
  private client = axios.create({
    withCredentials: true
  })
  
  // Use this.client for ALL requests, including refresh
  async refreshToken() {
    return this.client.post('/auth/refresh', data)
  }
}

Best Practices

1. Token Lifecycle Management

  • Access tokens: Short-lived (20 minutes), stored in memory/localStorage
  • Refresh tokens: Long-lived (7 days), stored in HttpOnly cookies
  • Automatic cleanup: Remove expired tokens during refresh operations
  • Logout cleanup: Completely delete tokens, don't just revoke

2. Error Handling

// Robust error handling for token refresh
async handleTokenRefresh() {
  try {
    await this.refreshToken()
    return this.retryOriginalRequest()
  } catch (error) {
    // Clear local storage and redirect to login
    TokenStorageManager.clear()
    AuthManager.logout()
    throw error
  }
}

3. Security Headers

// Additional security headers
app.Use(async (context, next) =>
{
    context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
    context.Response.Headers.Add("X-Frame-Options", "DENY");
    context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
    await next();
});

4. Environment-Specific Configuration

// Environment-aware API client setup
const createApiClient = () => {
  const isDevelopment = import.meta.env.DEV
  
  return axios.create({
    baseURL: import.meta.env.VITE_API_URL,
    withCredentials: true,
    timeout: isDevelopment ? 30000 : 10000,
    headers: {
      'Content-Type': 'application/json',
    }
  })
}

Conclusion

Proper refresh token security requires careful coordination between:

  1. Backend cookie configuration with appropriate SameSite settings
  2. Frontend HTTP client configuration with credentials enabled
  3. CORS policy that allows credentials for specific origins
  4. Consistent error handling and token lifecycle management
Key Insight: SameSite=None (with Secure=true) is necessary for cross-origin scenarios, while withCredentials: true or credentials: 'include' is essential for the frontend to send cookies to different origins.