[{"data":1,"prerenderedAt":1871},["ShallowReactive",2],{"post-\u002Fblog\u002Fjwt-with-auto-refresh-in-cookies":3},{"id":4,"title":5,"body":6,"book":1860,"date":1861,"description":1862,"extension":1863,"meta":1864,"navigation":273,"path":1865,"seo":1866,"stem":1867,"tags":1868,"__hash__":1870},"blog\u002Fblog\u002Fjwt-with-auto-refresh-in-cookies.md","JWT with auto-refresh in cookies",{"type":7,"value":8,"toc":1847},"minimark",[9,14,18,31,39,47,54,58,66,81,84,87,91,94,110,113,367,370,390,407,414,418,428,435,624,633,636,640,643,650,914,917,939,942,946,949,952,1463,1466,1505,1509,1512,1552,1558,1580,1586,1589,1593,1620,1626,1629,1668,1692,1698,1702,1705,1745,1751,1755,1761,1830,1834,1837,1843],[10,11,13],"h2",{"id":12},"background","Background",[15,16,17],"p",{},"JWT tutorials usually stop at \"here's an access token, put it in the Authorization header\". That's enough to get an API rejecting unauthenticated requests, but it's not enough for a real app. A production setup needs to deal with:",[19,20,21,25,28],"ul",{},[22,23,24],"li",{},"Where the token lives in the browser",[22,26,27],{},"What happens when the access token expires",[22,29,30],{},"How the client refreshes without forcing the user to log back in",[15,32,33,34,38],{},"I built a small demo that covers all three in the shape you'd actually use in production. It's a single ",[35,36,37],"code",{},"Program.cs"," of about 240 lines.",[15,40,41],{},[42,43,44],"a",{"href":44,"rel":45},"https:\u002F\u002Fgithub.com\u002Fhaiilong\u002Fjwt-auth-demo",[46],"nofollow",[15,48,49,50,53],{},"A note on shape before we start: this isn't trying to be a \"minimum to demonstrate the concept\" sample. The patterns in it (cookie config, the ",[35,51,52],{},"OnMessageReceived"," hook, the auto-refresh middleware, the order in the pipeline) are the same patterns I'd use in a real ASP.NET Core API. The handful of toy parts (in-memory user store, hardcoded signing key, in-memory refresh-token dictionary) are clearly marked as toy and easy to swap. I'll list the production swaps near the end of the post.",[10,55,57],{"id":56},"two-tokens-not-one","Two tokens, not one",[15,59,60,61,65],{},"The first thing the demo does is hand out ",[62,63,64],"em",{},"two"," tokens on login:",[19,67,68,75],{},[22,69,70,74],{},[71,72,73],"strong",{},"Access token",", a JWT signed with HS256, very short lived (1 minute in the demo so the refresh is easy to test). Carried on every request to prove who you are.",[22,76,77,80],{},[71,78,79],{},"Refresh token",", a cryptographically random 32 bytes, longer lived (7 days). Used only to ask for a new access token.",[15,82,83],{},"The point of the split is blast radius. If the access token leaks (someone reads your network traffic, an extension scrapes it, the client gets compromised), the damage window is one minute. The refresh token is the long-lived secret and you keep it locked down because it only goes back to the server when you want to refresh.",[15,85,86],{},"For the demo I made the access token 1 minute long so you don't have to wait around to see the refresh fire. Production values are usually 5 to 15 minutes for the access token and 7 to 30 days for the refresh token.",[10,88,90],{"id":89},"where-the-tokens-live","Where the tokens live",[15,92,93],{},"Two common options:",[95,96,97,103],"ol",{},[22,98,99,102],{},[35,100,101],{},"localStorage"," in the browser. Easy to read from your JS code. Vulnerable to XSS: any script on your origin can read it.",[22,104,105,106,109],{},"HttpOnly cookies. Cannot be read from JS at all. Set by the server, sent automatically by the browser. Vulnerable to CSRF unless you mitigate (which is why the cookies in the demo are ",[35,107,108],{},"SameSite=Strict",").",[15,111,112],{},"The demo uses option 2. The login endpoint sets three cookies:",[114,115,120],"pre",{"className":116,"code":117,"language":118,"meta":119,"style":119},"language-csharp shiki shiki-themes one-light one-dark-pro","http.Response.Cookies.Append(\"X-Access-Token\", accessToken,\n    new CookieOptions\n    {\n        HttpOnly = true,\n        Secure = true,\n        SameSite = SameSiteMode.Strict,\n        Expires = DateTime.UtcNow.AddMinutes(jwtSettings.AccessTokenMinutes)\n    });\n\nhttp.Response.Cookies.Append(\"X-Refresh-Token\", refreshToken,\n    new CookieOptions { \u002F* same options, 7 day expiry *\u002F });\n\nhttp.Response.Cookies.Append(\"X-Username\", user.Username, cookieOpts);\n","csharp","",[35,121,122,166,176,182,198,210,228,262,268,275,304,322,327],{"__ignoreMap":119},[123,124,127,131,135,138,140,143,145,149,152,156,159,163],"span",{"class":125,"line":126},"line",1,[123,128,130],{"class":129},"s7GmK","http",[123,132,134],{"class":133},"s5ixo",".",[123,136,137],{"class":129},"Response",[123,139,134],{"class":133},[123,141,142],{"class":129},"Cookies",[123,144,134],{"class":133},[123,146,148],{"class":147},"sAdtL","Append",[123,150,151],{"class":133},"(",[123,153,155],{"class":154},"sDhpE","\"X-Access-Token\"",[123,157,158],{"class":133},", ",[123,160,162],{"class":161},"sz0mV","accessToken",[123,164,165],{"class":133},",\n",[123,167,169,172],{"class":125,"line":168},2,[123,170,171],{"class":133},"    new ",[123,173,175],{"class":174},"sC09Y","CookieOptions\n",[123,177,179],{"class":125,"line":178},3,[123,180,181],{"class":133},"    {\n",[123,183,185,188,192,196],{"class":125,"line":184},4,[123,186,187],{"class":161},"        HttpOnly",[123,189,191],{"class":190},"sknuh"," =",[123,193,195],{"class":194},"sAGMh"," true",[123,197,165],{"class":133},[123,199,201,204,206,208],{"class":125,"line":200},5,[123,202,203],{"class":161},"        Secure",[123,205,191],{"class":190},[123,207,195],{"class":194},[123,209,165],{"class":133},[123,211,213,216,218,221,223,226],{"class":125,"line":212},6,[123,214,215],{"class":161},"        SameSite",[123,217,191],{"class":190},[123,219,220],{"class":129}," SameSiteMode",[123,222,134],{"class":133},[123,224,225],{"class":129},"Strict",[123,227,165],{"class":133},[123,229,231,234,236,239,241,244,246,249,251,254,256,259],{"class":125,"line":230},7,[123,232,233],{"class":161},"        Expires",[123,235,191],{"class":190},[123,237,238],{"class":129}," DateTime",[123,240,134],{"class":133},[123,242,243],{"class":129},"UtcNow",[123,245,134],{"class":133},[123,247,248],{"class":147},"AddMinutes",[123,250,151],{"class":133},[123,252,253],{"class":129},"jwtSettings",[123,255,134],{"class":133},[123,257,258],{"class":129},"AccessTokenMinutes",[123,260,261],{"class":133},")\n",[123,263,265],{"class":125,"line":264},8,[123,266,267],{"class":133},"    });\n",[123,269,271],{"class":125,"line":270},9,[123,272,274],{"emptyLinePlaceholder":273},true,"\n",[123,276,278,280,282,284,286,288,290,292,294,297,299,302],{"class":125,"line":277},10,[123,279,130],{"class":129},[123,281,134],{"class":133},[123,283,137],{"class":129},[123,285,134],{"class":133},[123,287,142],{"class":129},[123,289,134],{"class":133},[123,291,148],{"class":147},[123,293,151],{"class":133},[123,295,296],{"class":154},"\"X-Refresh-Token\"",[123,298,158],{"class":133},[123,300,301],{"class":161},"refreshToken",[123,303,165],{"class":133},[123,305,307,309,312,315,319],{"class":125,"line":306},11,[123,308,171],{"class":133},[123,310,311],{"class":174},"CookieOptions",[123,313,314],{"class":133}," { ",[123,316,318],{"class":317},"sW2Sy","\u002F* same options, 7 day expiry *\u002F",[123,320,321],{"class":133}," });\n",[123,323,325],{"class":125,"line":324},12,[123,326,274],{"emptyLinePlaceholder":273},[123,328,330,332,334,336,338,340,342,344,346,349,351,354,356,359,361,364],{"class":125,"line":329},13,[123,331,130],{"class":129},[123,333,134],{"class":133},[123,335,137],{"class":129},[123,337,134],{"class":133},[123,339,142],{"class":129},[123,341,134],{"class":133},[123,343,148],{"class":147},[123,345,151],{"class":133},[123,347,348],{"class":154},"\"X-Username\"",[123,350,158],{"class":133},[123,352,353],{"class":129},"user",[123,355,134],{"class":133},[123,357,358],{"class":129},"Username",[123,360,158],{"class":133},[123,362,363],{"class":161},"cookieOpts",[123,365,366],{"class":133},");\n",[15,368,369],{},"The flags do real work:",[19,371,372,378,384],{},[22,373,374,377],{},[35,375,376],{},"HttpOnly = true",": JS in the browser cannot read or write this cookie. Closes the door on XSS-based token theft.",[22,379,380,383],{},[35,381,382],{},"Secure = true",": cookie is only sent over HTTPS. In production this is non-negotiable.",[22,385,386,389],{},[35,387,388],{},"SameSite = SameSiteMode.Strict",": cookie is not sent on cross-site requests at all. Closes the door on most CSRF attack patterns.",[15,391,392,393,396,397,400,401,404,405,134],{},"After hitting ",[35,394,395],{},"\u002Flogin",", open the browser's DevTools, Application tab, Cookies, and the localhost entry. The three cookies should be there with ",[35,398,399],{},"HttpOnly"," and ",[35,402,403],{},"Secure"," both checked and ",[35,406,108],{},[15,408,409],{},[410,411],"img",{"alt":412,"src":413},"Cookies in DevTools after login","\u002Fblog\u002Fjwt-with-auto-refresh-in-cookies\u002Fcookies-after-login.png",[10,415,417],{"id":416},"reading-the-jwt-from-a-cookie","Reading the JWT from a cookie",[15,419,420,423,424,427],{},[35,421,422],{},"AddJwtBearer"," defaults to looking for the token in the ",[35,425,426],{},"Authorization: Bearer \u003Ctoken>"," header. If your token lives in a cookie, the default doesn't help.",[15,429,430,431,434],{},"The hook is ",[35,432,433],{},"JwtBearerEvents.OnMessageReceived",":",[114,436,438],{"className":116,"code":437,"language":118,"meta":119,"style":119},".AddJwtBearer(options =>\n{\n    options.TokenValidationParameters = new TokenValidationParameters { ... };\n\n    options.Events = new JwtBearerEvents\n    {\n        OnMessageReceived = context =>\n        {\n            if (context.Request.Cookies.TryGetValue(\"X-Access-Token\", out var token))\n            {\n                context.Token = token;\n            }\n            return Task.CompletedTask;\n        }\n    };\n});\n",[35,439,440,454,459,479,483,499,503,515,520,564,569,586,591,606,612,618],{"__ignoreMap":119},[123,441,442,444,446,448,451],{"class":125,"line":126},[123,443,134],{"class":133},[123,445,422],{"class":147},[123,447,151],{"class":133},[123,449,450],{"class":129},"options",[123,452,453],{"class":133}," =>\n",[123,455,456],{"class":125,"line":168},[123,457,458],{"class":133},"{\n",[123,460,461,464,466,469,471,474,476],{"class":125,"line":178},[123,462,463],{"class":129},"    options",[123,465,134],{"class":133},[123,467,468],{"class":129},"TokenValidationParameters",[123,470,191],{"class":190},[123,472,473],{"class":133}," new ",[123,475,468],{"class":174},[123,477,478],{"class":133}," { ... };\n",[123,480,481],{"class":125,"line":184},[123,482,274],{"emptyLinePlaceholder":273},[123,484,485,487,489,492,494,496],{"class":125,"line":200},[123,486,463],{"class":129},[123,488,134],{"class":133},[123,490,491],{"class":129},"Events",[123,493,191],{"class":190},[123,495,473],{"class":133},[123,497,498],{"class":174},"JwtBearerEvents\n",[123,500,501],{"class":125,"line":212},[123,502,181],{"class":133},[123,504,505,508,510,513],{"class":125,"line":230},[123,506,507],{"class":161},"        OnMessageReceived",[123,509,191],{"class":190},[123,511,512],{"class":129}," context",[123,514,453],{"class":133},[123,516,517],{"class":125,"line":264},[123,518,519],{"class":133},"        {\n",[123,521,522,526,529,532,534,537,539,541,543,546,548,550,552,555,558,561],{"class":125,"line":270},[123,523,525],{"class":524},"sLKXg","            if",[123,527,528],{"class":133}," (",[123,530,531],{"class":129},"context",[123,533,134],{"class":133},[123,535,536],{"class":129},"Request",[123,538,134],{"class":133},[123,540,142],{"class":129},[123,542,134],{"class":133},[123,544,545],{"class":147},"TryGetValue",[123,547,151],{"class":133},[123,549,155],{"class":154},[123,551,158],{"class":133},[123,553,554],{"class":524},"out",[123,556,557],{"class":524}," var",[123,559,560],{"class":161}," token",[123,562,563],{"class":133},"))\n",[123,565,566],{"class":125,"line":277},[123,567,568],{"class":133},"            {\n",[123,570,571,574,576,579,581,583],{"class":125,"line":306},[123,572,573],{"class":129},"                context",[123,575,134],{"class":133},[123,577,578],{"class":129},"Token",[123,580,191],{"class":190},[123,582,560],{"class":161},[123,584,585],{"class":133},";\n",[123,587,588],{"class":125,"line":324},[123,589,590],{"class":133},"            }\n",[123,592,593,596,599,601,604],{"class":125,"line":329},[123,594,595],{"class":524},"            return",[123,597,598],{"class":129}," Task",[123,600,134],{"class":133},[123,602,603],{"class":129},"CompletedTask",[123,605,585],{"class":133},[123,607,609],{"class":125,"line":608},14,[123,610,611],{"class":133},"        }\n",[123,613,615],{"class":125,"line":614},15,[123,616,617],{"class":133},"    };\n",[123,619,621],{"class":125,"line":620},16,[123,622,623],{"class":133},"});\n",[15,625,626,628,629,632],{},[35,627,52],{}," runs at the start of authentication on every request. Set ",[35,630,631],{},"context.Token",", and the rest of the bearer pipeline (signature validation, claims extraction, lifetime check) just uses that value as if it had come from the Authorization header. You don't have to touch anything else in ASP.NET Core's auth machinery.",[15,634,635],{},"This is the right hook for any \"the JWT lives somewhere other than the standard header\" requirement: cookie, custom header, a query string for SSE or WebSocket connections, anywhere.",[10,637,639],{"id":638},"the-naive-refresh-flow","The naive refresh flow",[15,641,642],{},"Before getting to the auto-refresh, here's what the manual flow looks like.",[15,644,645,646,649],{},"The demo exposes a ",[35,647,648],{},"\u002Frefresh"," endpoint:",[114,651,653],{"className":116,"code":652,"language":118,"meta":119,"style":119},"app.MapPost(\"\u002Frefresh\", (HttpContext http) =>\n{\n    http.Request.Cookies.TryGetValue(\"X-Refresh-Token\", out var cookieRefreshToken);\n    http.Request.Cookies.TryGetValue(\"X-Username\", out var cookieUsername);\n\n    if (string.IsNullOrEmpty(cookieRefreshToken) || string.IsNullOrEmpty(cookieUsername))\n        return Results.Unauthorized();\n\n    if (!userRefreshTokenDict.TryGetValue(cookieUsername, out var storedRefreshToken)\n        || storedRefreshToken != cookieRefreshToken)\n        return Results.Forbid();\n\n    \u002F\u002F generate new tokens, set new cookies, rotate the server-side refresh-token entry\n    \u002F\u002F ...\n    return Results.Ok(new { message = \"Refreshed via Cookies\" });\n});\n",[35,654,655,682,686,718,749,753,793,809,813,844,858,871,875,880,885,910],{"__ignoreMap":119},[123,656,657,660,662,665,667,670,673,676,679],{"class":125,"line":126},[123,658,659],{"class":129},"app",[123,661,134],{"class":133},[123,663,664],{"class":147},"MapPost",[123,666,151],{"class":133},[123,668,669],{"class":154},"\"\u002Frefresh\"",[123,671,672],{"class":133},", (",[123,674,675],{"class":174},"HttpContext",[123,677,678],{"class":129}," http",[123,680,681],{"class":133},") =>\n",[123,683,684],{"class":125,"line":168},[123,685,458],{"class":133},[123,687,688,691,693,695,697,699,701,703,705,707,709,711,713,716],{"class":125,"line":178},[123,689,690],{"class":129},"    http",[123,692,134],{"class":133},[123,694,536],{"class":129},[123,696,134],{"class":133},[123,698,142],{"class":129},[123,700,134],{"class":133},[123,702,545],{"class":147},[123,704,151],{"class":133},[123,706,296],{"class":154},[123,708,158],{"class":133},[123,710,554],{"class":524},[123,712,557],{"class":524},[123,714,715],{"class":161}," cookieRefreshToken",[123,717,366],{"class":133},[123,719,720,722,724,726,728,730,732,734,736,738,740,742,744,747],{"class":125,"line":184},[123,721,690],{"class":129},[123,723,134],{"class":133},[123,725,536],{"class":129},[123,727,134],{"class":133},[123,729,142],{"class":129},[123,731,134],{"class":133},[123,733,545],{"class":147},[123,735,151],{"class":133},[123,737,348],{"class":154},[123,739,158],{"class":133},[123,741,554],{"class":524},[123,743,557],{"class":524},[123,745,746],{"class":161}," cookieUsername",[123,748,366],{"class":133},[123,750,751],{"class":125,"line":200},[123,752,274],{"emptyLinePlaceholder":273},[123,754,755,758,760,763,765,768,770,773,776,779,782,784,786,788,791],{"class":125,"line":212},[123,756,757],{"class":524},"    if",[123,759,528],{"class":133},[123,761,762],{"class":524},"string",[123,764,134],{"class":133},[123,766,767],{"class":147},"IsNullOrEmpty",[123,769,151],{"class":133},[123,771,772],{"class":161},"cookieRefreshToken",[123,774,775],{"class":133},") ",[123,777,778],{"class":190},"||",[123,780,781],{"class":524}," string",[123,783,134],{"class":133},[123,785,767],{"class":147},[123,787,151],{"class":133},[123,789,790],{"class":161},"cookieUsername",[123,792,563],{"class":133},[123,794,795,798,801,803,806],{"class":125,"line":230},[123,796,797],{"class":524},"        return",[123,799,800],{"class":129}," Results",[123,802,134],{"class":133},[123,804,805],{"class":147},"Unauthorized",[123,807,808],{"class":133},"();\n",[123,810,811],{"class":125,"line":264},[123,812,274],{"emptyLinePlaceholder":273},[123,814,815,817,819,822,825,827,829,831,833,835,837,839,842],{"class":125,"line":270},[123,816,757],{"class":524},[123,818,528],{"class":133},[123,820,821],{"class":190},"!",[123,823,824],{"class":129},"userRefreshTokenDict",[123,826,134],{"class":133},[123,828,545],{"class":147},[123,830,151],{"class":133},[123,832,790],{"class":161},[123,834,158],{"class":133},[123,836,554],{"class":524},[123,838,557],{"class":524},[123,840,841],{"class":161}," storedRefreshToken",[123,843,261],{"class":133},[123,845,846,849,851,854,856],{"class":125,"line":277},[123,847,848],{"class":190},"        ||",[123,850,841],{"class":161},[123,852,853],{"class":190}," !=",[123,855,715],{"class":161},[123,857,261],{"class":133},[123,859,860,862,864,866,869],{"class":125,"line":306},[123,861,797],{"class":524},[123,863,800],{"class":129},[123,865,134],{"class":133},[123,867,868],{"class":147},"Forbid",[123,870,808],{"class":133},[123,872,873],{"class":125,"line":324},[123,874,274],{"emptyLinePlaceholder":273},[123,876,877],{"class":125,"line":329},[123,878,879],{"class":317},"    \u002F\u002F generate new tokens, set new cookies, rotate the server-side refresh-token entry\n",[123,881,882],{"class":125,"line":608},[123,883,884],{"class":317},"    \u002F\u002F ...\n",[123,886,887,890,892,894,897,900,903,905,908],{"class":125,"line":614},[123,888,889],{"class":524},"    return",[123,891,800],{"class":129},[123,893,134],{"class":133},[123,895,896],{"class":147},"Ok",[123,898,899],{"class":133},"(new { ",[123,901,902],{"class":161},"message",[123,904,191],{"class":190},[123,906,907],{"class":154}," \"Refreshed via Cookies\"",[123,909,321],{"class":133},[123,911,912],{"class":125,"line":620},[123,913,623],{"class":133},[15,915,916],{},"In a manual flow, the client side looks like:",[95,918,919,925,928,934],{},[22,920,921,922,134],{},"Client requests ",[35,923,924],{},"\u002Fdashboard",[22,926,927],{},"Server returns 401 because the access token expired.",[22,929,930,931,933],{},"Client sees the 401, calls ",[35,932,648],{}," to get new tokens.",[22,935,936,937,134],{},"Client retries ",[35,938,924],{},[15,940,941],{},"This works. It's three round trips for every protected request that catches an expired token. Every page open in the user's browser at the moment the token expires has to do its own version of this dance.",[10,943,945],{"id":944},"auto-refresh-in-middleware","Auto-refresh, in middleware",[15,947,948],{},"The better version: the server handles the refresh transparently in the middleware pipeline, before authentication runs. The client only ever sees a successful response. The 401 dance disappears.",[15,950,951],{},"Here's the middleware:",[114,953,955],{"className":116,"code":954,"language":118,"meta":119,"style":119},"app.Use(async (context, next) =>\n{\n    var accessToken = context.Request.Cookies[\"X-Access-Token\"];\n\n    if (IsTokenExpired(accessToken)\n        && context.Request.Cookies.TryGetValue(\"X-Refresh-Token\", out var refreshToken)\n        && context.Request.Cookies.TryGetValue(\"X-Username\", out var username))\n    {\n        if (userRefreshTokenDict.TryGetValue(username!, out var storedRefreshToken)\n            && storedRefreshToken == refreshToken)\n        {\n            var user = users.FirstOrDefault(u => u.Username == username);\n            if (user != null)\n            {\n                \u002F\u002F 1. Generate new tokens\n                var newAccessToken = GenerateAccessToken(...);\n                var newRefreshToken = GenerateRefreshToken();\n\n                \u002F\u002F 2. Update the server-side refresh-token store\n                userRefreshTokenDict[username!] = newRefreshToken;\n\n                \u002F\u002F 3. Write the new tokens to the response as fresh cookies\n                context.Response.Cookies.Append(\"X-Access-Token\", newAccessToken, ...);\n                context.Response.Cookies.Append(\"X-Refresh-Token\", newRefreshToken, ...);\n\n                \u002F\u002F 4. IMPORTANT: inject the new token into the CURRENT REQUEST's headers\n                \u002F\u002F so UseAuthentication sees a valid token and lets this request through\n                context.Request.Headers.Append(\"Authorization\", \"Bearer \" + newAccessToken);\n            }\n        }\n    }\n\n    await next();\n});\n\napp.UseAuthentication();\napp.UseAuthorization();\n",[35,956,957,982,986,1014,1018,1033,1067,1100,1104,1134,1148,1152,1190,1205,1209,1214,1230,1245,1250,1256,1278,1283,1289,1318,1346,1351,1357,1363,1398,1403,1408,1414,1419,1429,1434,1439,1451],{"__ignoreMap":119},[123,958,959,961,963,966,968,971,973,975,977,980],{"class":125,"line":126},[123,960,659],{"class":129},[123,962,134],{"class":133},[123,964,965],{"class":147},"Use",[123,967,151],{"class":133},[123,969,970],{"class":524},"async",[123,972,528],{"class":133},[123,974,531],{"class":129},[123,976,158],{"class":133},[123,978,979],{"class":129},"next",[123,981,681],{"class":133},[123,983,984],{"class":125,"line":168},[123,985,458],{"class":133},[123,987,988,991,994,996,998,1000,1002,1004,1006,1009,1011],{"class":125,"line":178},[123,989,990],{"class":524},"    var",[123,992,993],{"class":161}," accessToken",[123,995,191],{"class":190},[123,997,512],{"class":129},[123,999,134],{"class":133},[123,1001,536],{"class":129},[123,1003,134],{"class":133},[123,1005,142],{"class":129},[123,1007,1008],{"class":133},"[",[123,1010,155],{"class":154},[123,1012,1013],{"class":133},"];\n",[123,1015,1016],{"class":125,"line":184},[123,1017,274],{"emptyLinePlaceholder":273},[123,1019,1020,1022,1024,1027,1029,1031],{"class":125,"line":200},[123,1021,757],{"class":524},[123,1023,528],{"class":133},[123,1025,1026],{"class":147},"IsTokenExpired",[123,1028,151],{"class":133},[123,1030,162],{"class":161},[123,1032,261],{"class":133},[123,1034,1035,1038,1040,1042,1044,1046,1048,1050,1052,1054,1056,1058,1060,1062,1065],{"class":125,"line":212},[123,1036,1037],{"class":190},"        &&",[123,1039,512],{"class":129},[123,1041,134],{"class":133},[123,1043,536],{"class":129},[123,1045,134],{"class":133},[123,1047,142],{"class":129},[123,1049,134],{"class":133},[123,1051,545],{"class":147},[123,1053,151],{"class":133},[123,1055,296],{"class":154},[123,1057,158],{"class":133},[123,1059,554],{"class":524},[123,1061,557],{"class":524},[123,1063,1064],{"class":161}," refreshToken",[123,1066,261],{"class":133},[123,1068,1069,1071,1073,1075,1077,1079,1081,1083,1085,1087,1089,1091,1093,1095,1098],{"class":125,"line":230},[123,1070,1037],{"class":190},[123,1072,512],{"class":129},[123,1074,134],{"class":133},[123,1076,536],{"class":129},[123,1078,134],{"class":133},[123,1080,142],{"class":129},[123,1082,134],{"class":133},[123,1084,545],{"class":147},[123,1086,151],{"class":133},[123,1088,348],{"class":154},[123,1090,158],{"class":133},[123,1092,554],{"class":524},[123,1094,557],{"class":524},[123,1096,1097],{"class":161}," username",[123,1099,563],{"class":133},[123,1101,1102],{"class":125,"line":264},[123,1103,181],{"class":133},[123,1105,1106,1109,1111,1113,1115,1117,1119,1122,1124,1126,1128,1130,1132],{"class":125,"line":270},[123,1107,1108],{"class":524},"        if",[123,1110,528],{"class":133},[123,1112,824],{"class":129},[123,1114,134],{"class":133},[123,1116,545],{"class":147},[123,1118,151],{"class":133},[123,1120,1121],{"class":161},"username",[123,1123,821],{"class":190},[123,1125,158],{"class":133},[123,1127,554],{"class":524},[123,1129,557],{"class":524},[123,1131,841],{"class":161},[123,1133,261],{"class":133},[123,1135,1136,1139,1141,1144,1146],{"class":125,"line":277},[123,1137,1138],{"class":190},"            &&",[123,1140,841],{"class":161},[123,1142,1143],{"class":190}," ==",[123,1145,1064],{"class":161},[123,1147,261],{"class":133},[123,1149,1150],{"class":125,"line":306},[123,1151,519],{"class":133},[123,1153,1154,1157,1160,1162,1165,1167,1170,1172,1175,1178,1180,1182,1184,1186,1188],{"class":125,"line":324},[123,1155,1156],{"class":524},"            var",[123,1158,1159],{"class":161}," user",[123,1161,191],{"class":190},[123,1163,1164],{"class":129}," users",[123,1166,134],{"class":133},[123,1168,1169],{"class":147},"FirstOrDefault",[123,1171,151],{"class":133},[123,1173,1174],{"class":129},"u",[123,1176,1177],{"class":133}," => ",[123,1179,1174],{"class":129},[123,1181,134],{"class":133},[123,1183,358],{"class":129},[123,1185,1143],{"class":190},[123,1187,1097],{"class":161},[123,1189,366],{"class":133},[123,1191,1192,1194,1196,1198,1200,1203],{"class":125,"line":329},[123,1193,525],{"class":524},[123,1195,528],{"class":133},[123,1197,353],{"class":161},[123,1199,853],{"class":190},[123,1201,1202],{"class":194}," null",[123,1204,261],{"class":133},[123,1206,1207],{"class":125,"line":608},[123,1208,568],{"class":133},[123,1210,1211],{"class":125,"line":614},[123,1212,1213],{"class":317},"                \u002F\u002F 1. Generate new tokens\n",[123,1215,1216,1219,1222,1224,1227],{"class":125,"line":620},[123,1217,1218],{"class":524},"                var",[123,1220,1221],{"class":161}," newAccessToken",[123,1223,191],{"class":190},[123,1225,1226],{"class":147}," GenerateAccessToken",[123,1228,1229],{"class":133},"(...);\n",[123,1231,1233,1235,1238,1240,1243],{"class":125,"line":1232},17,[123,1234,1218],{"class":524},[123,1236,1237],{"class":161}," newRefreshToken",[123,1239,191],{"class":190},[123,1241,1242],{"class":147}," GenerateRefreshToken",[123,1244,808],{"class":133},[123,1246,1248],{"class":125,"line":1247},18,[123,1249,274],{"emptyLinePlaceholder":273},[123,1251,1253],{"class":125,"line":1252},19,[123,1254,1255],{"class":317},"                \u002F\u002F 2. Update the server-side refresh-token store\n",[123,1257,1259,1262,1264,1266,1268,1271,1274,1276],{"class":125,"line":1258},20,[123,1260,1261],{"class":129},"                userRefreshTokenDict",[123,1263,1008],{"class":133},[123,1265,1121],{"class":161},[123,1267,821],{"class":190},[123,1269,1270],{"class":133},"] ",[123,1272,1273],{"class":190},"=",[123,1275,1237],{"class":161},[123,1277,585],{"class":133},[123,1279,1281],{"class":125,"line":1280},21,[123,1282,274],{"emptyLinePlaceholder":273},[123,1284,1286],{"class":125,"line":1285},22,[123,1287,1288],{"class":317},"                \u002F\u002F 3. Write the new tokens to the response as fresh cookies\n",[123,1290,1292,1294,1296,1298,1300,1302,1304,1306,1308,1310,1312,1315],{"class":125,"line":1291},23,[123,1293,573],{"class":129},[123,1295,134],{"class":133},[123,1297,137],{"class":129},[123,1299,134],{"class":133},[123,1301,142],{"class":129},[123,1303,134],{"class":133},[123,1305,148],{"class":147},[123,1307,151],{"class":133},[123,1309,155],{"class":154},[123,1311,158],{"class":133},[123,1313,1314],{"class":161},"newAccessToken",[123,1316,1317],{"class":133},", ...);\n",[123,1319,1321,1323,1325,1327,1329,1331,1333,1335,1337,1339,1341,1344],{"class":125,"line":1320},24,[123,1322,573],{"class":129},[123,1324,134],{"class":133},[123,1326,137],{"class":129},[123,1328,134],{"class":133},[123,1330,142],{"class":129},[123,1332,134],{"class":133},[123,1334,148],{"class":147},[123,1336,151],{"class":133},[123,1338,296],{"class":154},[123,1340,158],{"class":133},[123,1342,1343],{"class":161},"newRefreshToken",[123,1345,1317],{"class":133},[123,1347,1349],{"class":125,"line":1348},25,[123,1350,274],{"emptyLinePlaceholder":273},[123,1352,1354],{"class":125,"line":1353},26,[123,1355,1356],{"class":317},"                \u002F\u002F 4. IMPORTANT: inject the new token into the CURRENT REQUEST's headers\n",[123,1358,1360],{"class":125,"line":1359},27,[123,1361,1362],{"class":317},"                \u002F\u002F so UseAuthentication sees a valid token and lets this request through\n",[123,1364,1366,1368,1370,1372,1374,1377,1379,1381,1383,1386,1388,1391,1394,1396],{"class":125,"line":1365},28,[123,1367,573],{"class":129},[123,1369,134],{"class":133},[123,1371,536],{"class":129},[123,1373,134],{"class":133},[123,1375,1376],{"class":129},"Headers",[123,1378,134],{"class":133},[123,1380,148],{"class":147},[123,1382,151],{"class":133},[123,1384,1385],{"class":154},"\"Authorization\"",[123,1387,158],{"class":133},[123,1389,1390],{"class":154},"\"Bearer \"",[123,1392,1393],{"class":190}," +",[123,1395,1221],{"class":161},[123,1397,366],{"class":133},[123,1399,1401],{"class":125,"line":1400},29,[123,1402,590],{"class":133},[123,1404,1406],{"class":125,"line":1405},30,[123,1407,611],{"class":133},[123,1409,1411],{"class":125,"line":1410},31,[123,1412,1413],{"class":133},"    }\n",[123,1415,1417],{"class":125,"line":1416},32,[123,1418,274],{"emptyLinePlaceholder":273},[123,1420,1422,1425,1427],{"class":125,"line":1421},33,[123,1423,1424],{"class":133},"    await ",[123,1426,979],{"class":147},[123,1428,808],{"class":133},[123,1430,1432],{"class":125,"line":1431},34,[123,1433,623],{"class":133},[123,1435,1437],{"class":125,"line":1436},35,[123,1438,274],{"emptyLinePlaceholder":273},[123,1440,1442,1444,1446,1449],{"class":125,"line":1441},36,[123,1443,659],{"class":129},[123,1445,134],{"class":133},[123,1447,1448],{"class":147},"UseAuthentication",[123,1450,808],{"class":133},[123,1452,1454,1456,1458,1461],{"class":125,"line":1453},37,[123,1455,659],{"class":129},[123,1457,134],{"class":133},[123,1459,1460],{"class":147},"UseAuthorization",[123,1462,808],{"class":133},[15,1464,1465],{},"Four steps:",[95,1467,1468,1474,1483,1489],{},[22,1469,1470,1473],{},[71,1471,1472],{},"Check if a refresh is needed."," Look at the access token cookie. If it's missing or expired, and there's a refresh token cookie and a username cookie, proceed.",[22,1475,1476,1479,1480,1482],{},[71,1477,1478],{},"Validate the refresh token against the server-side store."," This is the part that makes refresh tokens useful: the client doesn't get to declare what's valid. The server has the last say. If the client's refresh token doesn't match what we have on file, the refresh fails silently and the request falls through to be rejected by ",[35,1481,1448],{}," normally.",[22,1484,1485,1488],{},[71,1486,1487],{},"Issue new tokens and write them as cookies on the response."," The browser picks them up and uses them on the next request automatically. The user is now \"logged in again\" without clicking anything.",[22,1490,1491,1498,1499,1501,1502,1504],{},[71,1492,1493,1494,1497],{},"Inject the new access token into the current request's ",[35,1495,1496],{},"Authorization"," header."," This is the bit that makes the refresh transparent. Without it, the middleware would refresh the token for future requests, but the current request would still hit ",[35,1500,1448],{}," with the expired (or missing) cookie token, fail, and return 401. The injection puts the new token where ",[35,1503,1448],{}," looks for it, so the request that triggered the refresh also succeeds.",[10,1506,1508],{"id":1507},"order-of-operations","Order of operations",[15,1510,1511],{},"Pipeline order is doing real work here:",[114,1513,1515],{"className":116,"code":1514,"language":118,"meta":119,"style":119},"app.Use(\u002F* auto-refresh *\u002F);\napp.UseAuthentication();\napp.UseAuthorization();\n",[35,1516,1517,1532,1542],{"__ignoreMap":119},[123,1518,1519,1521,1523,1525,1527,1530],{"class":125,"line":126},[123,1520,659],{"class":129},[123,1522,134],{"class":133},[123,1524,965],{"class":147},[123,1526,151],{"class":133},[123,1528,1529],{"class":317},"\u002F* auto-refresh *\u002F",[123,1531,366],{"class":133},[123,1533,1534,1536,1538,1540],{"class":125,"line":168},[123,1535,659],{"class":129},[123,1537,134],{"class":133},[123,1539,1448],{"class":147},[123,1541,808],{"class":133},[123,1543,1544,1546,1548,1550],{"class":125,"line":178},[123,1545,659],{"class":129},[123,1547,134],{"class":133},[123,1549,1460],{"class":147},[123,1551,808],{"class":133},[15,1553,1554,1555,1557],{},"The auto-refresh middleware has to run BEFORE ",[35,1556,1448],{},", because:",[19,1559,1560,1568,1574],{},[22,1561,1562,1564,1565,134],{},[35,1563,1448],{}," is what reads the JWT, validates the signature, and builds the ",[35,1566,1567],{},"ClaimsPrincipal",[22,1569,1570,1571,1573],{},"If ",[35,1572,1448],{}," runs first with an expired token, it rejects the request. There's no opportunity to refresh.",[22,1575,1576,1577,1579],{},"The auto-refresh middleware needs to have rewritten the request before ",[35,1578,1448],{}," reads it.",[15,1581,1582,1583,1585],{},"Put the refresh middleware after ",[35,1584,1448],{}," and every protected request with an expired token returns 401, which puts you back on the manual flow.",[15,1587,1588],{},"There's a subtle point in step 4 above. Just setting the cookie on the response doesn't help the current request, because cookies are read from the request, not the response. The browser hasn't seen the new cookie yet; it's still attached to the old one for this round trip. The injection into the request headers is what lets the current request go through with the new token.",[10,1590,1592],{"id":1591},"walking-through-it","Walking through it",[15,1594,1595,1596,1599,1600,1603,1604,400,1606,1608,1609,1611,1612,1615,1616,1619],{},"Run the demo with ",[35,1597,1598],{},"dotnet run",", then navigate to the URL the console shows (something like ",[35,1601,1602],{},"http:\u002F\u002Flocalhost:5265\u002Fscalar","). The Scalar UI lists all four endpoints. ",[35,1605,395],{},[35,1607,648],{}," are public. ",[35,1610,924],{}," requires any authenticated user, and ",[35,1613,1614],{},"\u002Fadmin-only"," requires the ",[35,1617,1618],{},"Admin"," role on top of that.",[15,1621,1622],{},[410,1623],{"alt":1624,"src":1625},"Scalar UI listing the endpoints","\u002Fblog\u002Fjwt-with-auto-refresh-in-cookies\u002Fscalar-endpoints.png",[15,1627,1628],{},"The walkthrough:",[95,1630,1631,1644,1653,1656],{},[22,1632,1633,1634,1636,1637,1640,1641,134],{},"Hit ",[35,1635,395],{}," with ",[35,1638,1639],{},"{\"username\":\"admin\",\"password\":\"password\"}",". You're logged in. Cookies are set. The response body is ",[35,1642,1643],{},"{\"message\":\"Logged in via Cookies\"}",[22,1645,1633,1646,1648,1649,1652],{},[35,1647,924],{},". Returns ",[35,1650,1651],{},"Hello User!",". Same connection, same cookies, no fuss.",[22,1654,1655],{},"Wait one minute. The access token is now expired.",[22,1657,1633,1658,1660,1661,1663,1664,1667],{},[35,1659,924],{}," again. Returns ",[35,1662,1651],{},". ",[71,1665,1666],{},"No 401, no client-side retry, no client involvement."," The auto-refresh middleware noticed the access token was expired, validated the refresh token, issued a new access token, set fresh cookies on the response, injected the new token into the request, and let it flow through. From the client's side, the request just worked.",[15,1669,1670,1671,1673,1674,1677,1678,400,1681,1684,1685,1688,1689,1691],{},"That last request, the post-expiry ",[35,1672,924],{}," call, is the one to watch. Open DevTools, Network tab, before you make it. After the request, click the entry, and look at the Response Headers panel. You should see two fresh ",[35,1675,1676],{},"Set-Cookie"," lines (",[35,1679,1680],{},"X-Access-Token=...",[35,1682,1683],{},"X-Refresh-Token=...",") on a ",[35,1686,1687],{},"200 OK"," response for ",[35,1690,924],{},". That's the auto-refresh: the server issued new credentials inside a request that, from the client's side, was a normal call.",[15,1693,1694],{},[410,1695],{"alt":1696,"src":1697},"Network panel showing Set-Cookie on a 200 OK dashboard response","\u002Fblog\u002Fjwt-with-auto-refresh-in-cookies\u002Fauto-refresh-network.png",[10,1699,1701],{"id":1700},"what-happens-on-failure","What happens on failure",[15,1703,1704],{},"Scenarios the middleware needs to handle, and how the demo handles them:",[19,1706,1707,1724,1730,1736],{},[22,1708,1709,1712,1713,1716,1717,1720,1721,1723],{},[71,1710,1711],{},"No access token cookie at all."," ",[35,1714,1715],{},"IsTokenExpired(null)"," returns ",[35,1718,1719],{},"true",". If there's no refresh token cookie either, the middleware does nothing, and ",[35,1722,1448],{}," returns 401.",[22,1725,1726,1729],{},[71,1727,1728],{},"Access token expired, refresh token missing."," Middleware does nothing, request falls through to 401. The user logs in again.",[22,1731,1732,1735],{},[71,1733,1734],{},"Access token expired, refresh token present but doesn't match the server's record."," Lookup fails, middleware does nothing, request gets 401. This is what protects against a stolen refresh token: if the server's record doesn't match (because the user logged out, or the refresh was rotated by another request), the stolen token is useless.",[22,1737,1738,1712,1741,1744],{},[71,1739,1740],{},"Refresh token matches but the user no longer exists.",[35,1742,1743],{},"users.FirstOrDefault(...)"," returns null, middleware does nothing, request gets 401.",[15,1746,1747,1748,1750],{},"The shape in all four is \"if anything fails, let the request fall through to ",[35,1749,1448],{}," and get rejected the normal way.\" The middleware never throws on its own. It either succeeds and rewrites the request, or it sits out and lets the existing auth layer say no.",[10,1752,1754],{"id":1753},"what-youd-add-for-production","What you'd add for production",[15,1756,1757,1758,1760],{},"The middleware, the cookie configuration, the ",[35,1759,52],{}," hook, the auto-refresh flow: all of those are production-shaped as-is. What needs to change is mostly the data plumbing:",[19,1762,1763,1776,1786,1796,1802,1808,1820],{},[22,1764,1765,1771,1772,1775],{},[71,1766,1767,1768,134],{},"Refresh tokens in ",[35,1769,1770],{},"Dictionary\u003Cstring, string>"," Replace with a database table (or Redis), keyed by user ID. Store the ",[71,1773,1774],{},"hash"," of the refresh token, not the token itself; compare hashes on validation. When you horizontally scale, the store needs to be shared, so process-local memory doesn't cut it.",[22,1777,1778,1781,1782,1785],{},[71,1779,1780],{},"Refresh token rotation with reuse detection."," The demo rotates on every refresh (",[35,1783,1784],{},"userRefreshTokenDict[username!] = newRefreshToken;","), which is correct. Production should also detect the \"reuse of an already-rotated refresh token\" case and revoke everything for that user; reuse means either a bug or a stolen token, and either way you want to invalidate.",[22,1787,1788,1791,1792,1795],{},[71,1789,1790],{},"Token versioning for revocation."," Add a ",[35,1793,1794],{},"tokenVersion"," claim to the JWT, compare against a value in the user table on every refresh. Lets you force-invalidate all active tokens (logout-from-all-devices, password change, suspected compromise).",[22,1797,1798,1801],{},[71,1799,1800],{},"Hardcoded signing key."," Move to configuration, ideally a managed secret store, rotated on a schedule. For higher-security setups, switch from HS256 (shared secret) to RS256 (public\u002Fprivate keypair) so verifiers don't need the signing key.",[22,1803,1804,1807],{},[71,1805,1806],{},"In-memory users with plaintext passwords."," Use the real user table. Passwords hashed with BCrypt or Argon2id, never plaintext or just-SHA256.",[22,1809,1810,1815,1816,1819],{},[71,1811,1812,1813,134],{},"CSRF beyond ",[35,1814,108],{}," Strict gets you most of the way. If you ever have to relax to ",[35,1817,1818],{},"Lax"," (cross-subdomain navigation, OAuth callbacks), add an anti-forgery token on state-changing endpoints.",[22,1821,1822,1825,1826,1829],{},[71,1823,1824],{},"HTTPS only."," Already enforced by ",[35,1827,1828],{},"Secure=true"," on the cookies, but the host itself should refuse HTTP entirely in production.",[10,1831,1833],{"id":1832},"closing","Closing",[15,1835,1836],{},"Clone the repo, run it, watch the cookies rotate in DevTools, and you'll have most of what you need to build this into a real codebase.",[15,1838,1839,1840],{},"Repo: ",[42,1841,44],{"href":44,"rel":1842},[46],[1844,1845,1846],"style",{},"html pre.shiki code .s7GmK, html code.shiki .s7GmK{--shiki-default:#383A42;--shiki-dark:#E5C07B}html pre.shiki code .s5ixo, html code.shiki .s5ixo{--shiki-default:#383A42;--shiki-dark:#ABB2BF}html pre.shiki code .sAdtL, html code.shiki .sAdtL{--shiki-default:#4078F2;--shiki-dark:#61AFEF}html pre.shiki code .sDhpE, html code.shiki .sDhpE{--shiki-default:#50A14F;--shiki-dark:#98C379}html pre.shiki code .sz0mV, html code.shiki .sz0mV{--shiki-default:#383A42;--shiki-dark:#E06C75}html pre.shiki code .sC09Y, html code.shiki .sC09Y{--shiki-default:#C18401;--shiki-dark:#E5C07B}html pre.shiki code .sknuh, html code.shiki .sknuh{--shiki-default:#383A42;--shiki-dark:#56B6C2}html pre.shiki code .sAGMh, html code.shiki .sAGMh{--shiki-default:#986801;--shiki-dark:#D19A66}html pre.shiki code .sW2Sy, html code.shiki .sW2Sy{--shiki-default:#A0A1A7;--shiki-default-font-style:italic;--shiki-dark:#7F848E;--shiki-dark-font-style:italic}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sLKXg, html code.shiki .sLKXg{--shiki-default:#A626A4;--shiki-dark:#C678DD}",{"title":119,"searchDepth":168,"depth":168,"links":1848},[1849,1850,1851,1852,1853,1854,1855,1856,1857,1858,1859],{"id":12,"depth":168,"text":13},{"id":56,"depth":168,"text":57},{"id":89,"depth":168,"text":90},{"id":416,"depth":168,"text":417},{"id":638,"depth":168,"text":639},{"id":944,"depth":168,"text":945},{"id":1507,"depth":168,"text":1508},{"id":1591,"depth":168,"text":1592},{"id":1700,"depth":168,"text":1701},{"id":1753,"depth":168,"text":1754},{"id":1832,"depth":168,"text":1833},null,"2025-12-05","A walkthrough of a small ASP.NET Core demo that handles access tokens, refresh tokens, and a middleware that refreshes them transparently.","md",{},"\u002Fblog\u002Fjwt-with-auto-refresh-in-cookies",{"title":5,"description":1862},"blog\u002Fjwt-with-auto-refresh-in-cookies",[1869],"tech","1i9fJ7H91Wcd4oTrXxDApOAxP3xdVHXK0aK8x33HRCI",1778998257279]