- The Login Request The user types their credentials in React and hits the login button.
React: Sends a POST request to your C# backend (/api/auth/login) with the username and password.
- The C# Backend Response Your C# controller validates the credentials and generates both tokens. Instead of sending both tokens back in the JSON body, it splits them up:
The Refresh Token is baked directly into a secure cookie header.
The Access Token is sent back as a standard JSON string.
[HttpPost("login")]
public IActionResult Login([FromBody] LoginDto model)
{
// 1. Validate user and generate tokens
string accessToken = GenerateJwtToken(model);
string refreshToken = GenerateRefreshToken();
// 2. Append the Refresh Token as a secure httpOnly cookie
Response.Cookies.Append("X-Refresh-Token", refreshToken, new CookieOptions
{
HttpOnly = true, // Blocks React/JavaScript from reading it
Secure = true, // Enforces HTTPS only
SameSite = SameSiteMode.Strict, // Defends against CSRF attacks
Expires = DateTimeOffset.UtcNow.AddDays(7),
Path = "/api/auth/refresh" // Browser ONLY sends it to the refresh endpoint
});
// 3. Send the Access Token back in the JSON body
return Ok(new { token = accessToken });
}
- React Receives the Tokens When the response arrives back to the frontend:
The Browser sees the Set-Cookie header and automatically saves the X-Refresh-Token cookie into its isolated cookie storage. Your React JavaScript code cannot read or modify this.
React reads the response body, extracts the accessToken, and saves it to an in-memory variable or state provider (like React Context, Zustand, or Redux).
// React Login State
const [accessToken, setAccessToken] = useState(null);
const handleLogin = async (credentials) => {
const response = await axios.post('/api/auth/login', credentials);
// Save access token to in-memory state
setAccessToken(response.data.token);
};
- Making Regular API Requests For subsequent requests (e.g., fetching a profile or dashboard data):
React attaches the in-memory accessTokento the HTTP Authorization header as a Bearer token.
The Browser does not send the refresh token cookie here because the path is restricted to /api/auth/refresh.
// Example of an authorized API call
const fetchDashboardData = async () => {
const response = await axios.get('/api/dashboard', {
headers: { Authorization: `Bearer ${accessToken}` }
});
return response.data;
};
- The Silent Refresh (When Access Token Expires)
Eventually, the 15-minute access token expires, and your C# API starts returning
401 Unauthorizederrors. React needs a new access token without forcing the user to log in again.
React makes a POST request to /api/auth/refresh. Crucially, it sets withCredentials: true in the request configuration.
The Browser sees the request matches the path /api/auth/refresh and automatically appends the hidden X-Refresh-Token cookie to the request headers behind the scenes.
C# Backend reads the cookie, validates it against the database, generates a new access token, and returns it in the JSON body.
React updates its in-memory accessTokenstate, and the user continues uninterrupted.
const refreshMyToken = async () => {
try {
const response = await axios.post('/api/auth/refresh', {}, {
withCredentials: true // MANDATORY: Forces the browser to send the httpOnly cookie
});
setAccessToken(response.data.token); // Store the new access token
} catch (err) {
// Both tokens are invalid/expired -> Send user back to Login page
setAccessToken(null);
}
};
What happens if the user refreshes the page?
Because the accessToken is in JavaScript memory, refreshing the page wipes it out. To fix this, simply call your refreshMyToken() function immediately when the React application loads up inside a useEffecthook. The browser will seamlessly send the refresh cookie, fetch a fresh access token, and hydrate your app state without the user noticing a thing.
Top comments (0)