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: trueenables 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:
- Backend cookie configuration with appropriate SameSite settings
- Frontend HTTP client configuration with credentials enabled
- CORS policy that allows credentials for specific origins
- 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.