[{"data":1,"prerenderedAt":380},["ShallowReactive",2],{"blog-diagrams/jwt-refresh-rotation":3},{"id":4,"title":5,"body":6,"category":347,"date":348,"dateModified":348,"description":349,"draft":350,"extension":351,"faq":352,"featured":350,"headerVariant":363,"image":364,"keywords":366,"meta":367,"navigation":166,"ogDescription":368,"ogTitle":369,"path":370,"readTime":371,"schemaOrg":372,"schemaType":373,"seo":374,"sitemap":375,"stem":376,"tags":377,"twitterCard":378,"__hash__":379},"blog/blog/diagrams/jwt-refresh-rotation.md","Refresh Token Rotation and Reuse Detection (Diagram)",{"type":7,"value":8,"toc":340},"minimark",[9,13,16,24,29,32,39,45,51,54,60,66,70,73,76,79,82,88,92,95,103,106,114,118,121,271,305,324,336],[10,11,12],"p",{},"Most JWT guides tell you to rotate refresh tokens and move on. The interesting part is what rotation buys you, and it is not really about the tokens being short-lived.",[10,14,15],{},"Rotation makes every refresh token single-use. That turns a second use into evidence.",[17,18],"diagram",{"alt":19,"caption":20,"height":21,"src":22,"width":23},"A state diagram of refresh token rotation. Login puts the session in a Signed in state where exactly one refresh token works. A normal refresh loops back to that same state, burning the old token. A second arrow leaves for a red state reached when the same token arrives twice, which only a copy can do, and that state leads to every token for the user being deleted.","One state, two exits. The second one only opens if somebody made a copy.",1302,"jwt-refresh-rotation",792,[25,26,28],"h2",{"id":27},"reading-the-diagram","Reading the diagram",[10,30,31],{},"There is only one normal state here, and the whole lesson is in what can leave it.",[10,33,34,38],{},[35,36,37],"strong",{},"The green box is the entire happy path."," Your user is signed in, and at any given moment exactly one refresh token is valid for them. Not a set of tokens. One.",[10,40,41,44],{},[35,42,43],{},"The loop is the normal case, and it does not change state."," Your app's access token expires after a few minutes, so it presents the refresh token, and the server hands back a new access token plus a new refresh token. The old refresh token is destroyed in the same operation. The session is exactly where it was, holding one valid token, just a different one. This happens dozens of times a day and nobody notices.",[10,46,47,50],{},[35,48,49],{},"The second arrow is the one worth understanding."," It fires when a token that was already burned shows up again.",[10,52,53],{},"Think about who can send that request. The legitimate client cannot, because it threw that token away the instant it received a replacement. It has no copy. So a burned token arriving at your server means two parties were holding the same token, and only one of them is the browser you issued it to.",[10,55,56,59],{},[35,57,58],{},"That is why the label says only a copy can do this."," Rotation does not stop a token from being stolen. Nothing at this layer does. What it does is guarantee that a stolen token cannot be used without producing a signal, because the thief and the real user are now racing to spend a token that only works once. Whoever loses that race triggers the alarm.",[10,61,62,65],{},[35,63,64],{},"The last box looks disproportionate until you work out the alternative."," Every refresh token for that user gets deleted, and they have to sign in again.",[25,67,69],{"id":68},"why-revoke-everything","Why revoke everything",[10,71,72],{},"Your server has just received a burned token. It knows two parties hold tokens from this family. It does not know which one is the customer.",[10,74,75],{},"Say the attacker refreshed first. They now hold the current valid token, and the burned one that just arrived came from your actual user. Refusing only the burned token signs your user out while leaving the attacker with a working session, which is precisely backwards.",[10,77,78],{},"Or your user refreshed first, and the burned token is the attacker's. Refusing just that request works, this time.",[10,80,81],{},"You cannot tell these apart from the request. Deleting the whole family is the only response that is correct under both, and the cost is one login prompt for a customer whose token was compromised anyway.",[83,84,85],"warning-box",{},[10,86,87],{},"Reuse detection has a real false-positive rate, and this is where teams quietly turn it off. A dropped connection that retries the refresh call, or two tabs refreshing in the same second, both send the same token twice with nobody stealing anything. Give the immediately-previous token a short grace window, a few seconds, where it is accepted without triggering revocation, and serialise refresh calls in your client so only one is in flight. Turning the detection off instead throws away the only signal you had.",[25,89,91],{"id":90},"what-this-looks-like-in-ai-generated-code","What this looks like in AI-generated code",[10,93,94],{},"Refresh endpoints are a place where generated code reliably produces something that works and does not protect anything.",[10,96,97,98,102],{},"The common shape is a ",[99,100,101],"code",{},"/refresh"," route that verifies the token's signature, checks it has not expired, and issues a new access token. That code passes every test you would think to write. It also lets one stolen refresh token be replayed indefinitely, because nothing is tracking which tokens have been spent.",[10,104,105],{},"Rotation needs server-side state. You have to store a hash of each issued refresh token, delete it on use, and treat a miss as an attack rather than as a plain 401. A model asked for \"a refresh endpoint\" is not going to volunteer any of that, and the version without it is shorter and looks cleaner.",[10,107,108,109,113],{},"The tell is quick to check: refresh once, then send the ",[110,111,112],"em",{},"same"," refresh token a second time. If you get a new access token back, you have no rotation and no detection.",[25,115,117],{"id":116},"the-source","The source",[10,119,120],{},"This diagram is generated from the file below, and the render is checked against it in CI. Copy it into any Mermaid renderer to remix it.",[122,123,128],"pre",{"className":124,"code":125,"language":126,"meta":127,"style":127},"language-mermaid shiki shiki-themes github-dark","---\ntitle: \"Reuse is how theft gets caught\"\n---\nstateDiagram-v2\n    direction TB\n\n    state \"Signed in.\u003Cbr/>One refresh token works.\" as Live\n    state \"A burned token came back.\u003Cbr/>\u003Cb>only a copy can do this\u003C/b>\" as Caught\n    state \"Every token for this user\u003Cbr/>is deleted. Log in again.\" as Revoked\n\n    [*] --> Live: login issues\u003Cbr/>token #1\n\n    Live --> Live: refreshing burns\u003Cbr/>the old token\n    Live --> Caught: the same token\u003Cbr/>arrives twice\n\n    Caught --> Revoked: one reuse is\u003Cbr/>proof of theft\n    Revoked --> [*]\n\n    classDef safe fill:#ecfdf5,stroke:#10b981,stroke-width:3px,color:#1c1917\n    classDef danger fill:#fef2f2,stroke:#ef4444,stroke-width:3px,color:#1c1917\n\n    class Live safe\n    class Caught danger\n    class Revoked safe\n","mermaid","",[99,129,130,138,144,149,155,161,168,174,180,186,191,197,202,208,214,219,225,231,236,242,248,253,259,265],{"__ignoreMap":127},[131,132,135],"span",{"class":133,"line":134},"line",1,[131,136,137],{},"---\n",[131,139,141],{"class":133,"line":140},2,[131,142,143],{},"title: \"Reuse is how theft gets caught\"\n",[131,145,147],{"class":133,"line":146},3,[131,148,137],{},[131,150,152],{"class":133,"line":151},4,[131,153,154],{},"stateDiagram-v2\n",[131,156,158],{"class":133,"line":157},5,[131,159,160],{},"    direction TB\n",[131,162,164],{"class":133,"line":163},6,[131,165,167],{"emptyLinePlaceholder":166},true,"\n",[131,169,171],{"class":133,"line":170},7,[131,172,173],{},"    state \"Signed in.\u003Cbr/>One refresh token works.\" as Live\n",[131,175,177],{"class":133,"line":176},8,[131,178,179],{},"    state \"A burned token came back.\u003Cbr/>\u003Cb>only a copy can do this\u003C/b>\" as Caught\n",[131,181,183],{"class":133,"line":182},9,[131,184,185],{},"    state \"Every token for this user\u003Cbr/>is deleted. Log in again.\" as Revoked\n",[131,187,189],{"class":133,"line":188},10,[131,190,167],{"emptyLinePlaceholder":166},[131,192,194],{"class":133,"line":193},11,[131,195,196],{},"    [*] --> Live: login issues\u003Cbr/>token #1\n",[131,198,200],{"class":133,"line":199},12,[131,201,167],{"emptyLinePlaceholder":166},[131,203,205],{"class":133,"line":204},13,[131,206,207],{},"    Live --> Live: refreshing burns\u003Cbr/>the old token\n",[131,209,211],{"class":133,"line":210},14,[131,212,213],{},"    Live --> Caught: the same token\u003Cbr/>arrives twice\n",[131,215,217],{"class":133,"line":216},15,[131,218,167],{"emptyLinePlaceholder":166},[131,220,222],{"class":133,"line":221},16,[131,223,224],{},"    Caught --> Revoked: one reuse is\u003Cbr/>proof of theft\n",[131,226,228],{"class":133,"line":227},17,[131,229,230],{},"    Revoked --> [*]\n",[131,232,234],{"class":133,"line":233},18,[131,235,167],{"emptyLinePlaceholder":166},[131,237,239],{"class":133,"line":238},19,[131,240,241],{},"    classDef safe fill:#ecfdf5,stroke:#10b981,stroke-width:3px,color:#1c1917\n",[131,243,245],{"class":133,"line":244},20,[131,246,247],{},"    classDef danger fill:#fef2f2,stroke:#ef4444,stroke-width:3px,color:#1c1917\n",[131,249,251],{"class":133,"line":250},21,[131,252,167],{"emptyLinePlaceholder":166},[131,254,256],{"class":133,"line":255},22,[131,257,258],{},"    class Live safe\n",[131,260,262],{"class":133,"line":261},23,[131,263,264],{},"    class Caught danger\n",[131,266,268],{"class":133,"line":267},24,[131,269,270],{},"    class Revoked safe\n",[272,273,274,281,287,293,299],"faq-section",{},[275,276,278],"faq-item",{"question":277},"What is refresh token rotation?",[10,279,280],{},"Every time your app trades a refresh token for a new access token, the server destroys the token that was used and issues a fresh one. Each refresh token works exactly once. Without rotation, a refresh token is a long-lived password: whoever holds it can mint access tokens for weeks, and using it leaves no trace.",[275,282,284],{"question":283},"What is refresh token reuse detection?",[10,285,286],{},"It's the check that fires when an already-burned refresh token is presented again. Under rotation that should be impossible for the legitimate client, because it discarded that token the moment it got a replacement. A second use therefore means two parties hold the same token, which means one of them copied it. The standard response is to delete every refresh token for that user.",[275,288,290],{"question":289},"Why does one reuse revoke every token instead of just that one?",[10,291,292],{},"Because you cannot tell which of the two holders is the real user. The request carrying the burned token might be the thief, or it might be the legitimate client whose replacement got lost. Refusing only the burned token leaves whoever holds the newer one signed in, and that could be the attacker. Deleting the whole family is the only response that is safe under both readings, and it costs one forced login.",[275,294,296],{"question":295},"Can reuse detection fire on a legitimate user?",[10,297,298],{},"Yes, and it's the main reason people disable it. A flaky network that retries a refresh, or two browser tabs refreshing at the same instant, can both present the same token. Fix it with a short grace window where the immediately-previous token is accepted without triggering revocation, plus serialising refresh calls in the client so only one is ever in flight.",[275,300,302],{"question":301},"Do I need rotation if my refresh tokens are in httpOnly cookies?",[10,303,304],{},"An httpOnly cookie stops JavaScript on your page from reading the token, which closes the XSS path. It doesn't close the others: a leaked server log, a backup, a proxy that records headers, or malware on the user's machine. Rotation is what gives you a detection signal after a token has left by one of those routes. Storage alone never provides that.",[306,307,308,314,319],"related-articles",{},[309,310],"related-card",{"description":311,"href":312,"title":313},"Signing algorithm, claims, storage, rotation and revocation, step by step","/blog/how-to/jwt-security","How to Secure JWTs",[309,315],{"description":316,"href":317,"title":318},"The companion diagram: why a stateless access token has only one exit from the live state","/blog/diagrams/session-lifecycle-and-the-jwt-gap","Where a JWT Cannot Be Revoked",[309,320],{"description":321,"href":322,"title":323},"Idle timeouts, absolute timeouts, and regenerating on a privilege change","/blog/how-to/session-management","How to Manage Sessions Securely",[325,326,329,333],"cta-box",{"href":327,"label":328},"/","Start Free Scan",[25,330,332],{"id":331},"does-your-refresh-endpoint-accept-the-same-token-twice","Does your refresh endpoint accept the same token twice?",[10,334,335],{},"A scan checks what your live app actually does with tokens, including endpoints that keep honouring credentials they should have burned.",[337,338,339],"style",{},"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);}",{"title":127,"searchDepth":140,"depth":140,"links":341},[342,343,344,345,346],{"id":27,"depth":140,"text":28},{"id":68,"depth":140,"text":69},{"id":90,"depth":140,"text":91},{"id":116,"depth":140,"text":117},{"id":331,"depth":140,"text":332},"diagrams","2026-08-13","A state diagram of refresh token rotation: every refresh burns the token it used, so a burned token coming back is proof somebody copied it.",false,"md",[353,355,357,359,361],{"question":277,"answer":354},"Every time your app trades a refresh token for a new access token, the server destroys the refresh token that was used and issues a fresh one. Each refresh token works exactly once. Without rotation a refresh token is a long-lived password: whoever holds it can mint access tokens for weeks, and using it leaves no trace.",{"question":283,"answer":356},"It is the check that fires when an already-burned refresh token is presented again. Under rotation that should be impossible for the legitimate client, because it discarded that token the moment it got a replacement. A second use therefore means two parties are holding the same token, which means one of them copied it. The standard response is to delete every refresh token for that user.",{"question":289,"answer":358},"Because you cannot tell which of the two holders is the real user. The request that arrives with the burned token might be the thief, or it might be the legitimate client whose replacement token got lost. Refusing only the burned token leaves whoever holds the newer one signed in, and that could be the attacker. Deleting the whole family is the only response that is safe under both readings, at the cost of one forced login.",{"question":295,"answer":360},"Yes, and it is the main reason people disable it. A flaky network that retries a refresh request, or two browser tabs refreshing at the same instant, can both present the same token. The usual fixes are a short grace window where the immediately-previous token is accepted without triggering revocation, and serialising refresh calls in the client so only one is ever in flight.",{"question":301,"answer":362},"An httpOnly cookie stops JavaScript on your page from reading the token, which closes the XSS path. It does not close the others: a leaked server log, a backup, a proxy that records request headers, or malware on the user's machine. Rotation is what gives you a detection signal after a token has left by one of those routes, which storage alone never provides.","blue",{"src":365,"alt":19},"https://checkyourvibe.dev/diagrams/jwt-refresh-rotation.png","refresh token rotation, refresh token reuse detection, jwt refresh token security, revoke token family, single use refresh token, stolen refresh token",{"ogImage":365},"Rotation makes every refresh token single-use. A second use is not an error, it is evidence.",null,"/blog/diagrams/jwt-refresh-rotation","5 min read","[object Object]","Article",{"title":5,"description":349},{"loc":370},"blog/diagrams/jwt-refresh-rotation",[],"summary_large_image","hcLH4ZmBZZ2uNBGrF50i9DigkiOE9o92sqNt6un4coU",1787602613802]