[{"data":1,"prerenderedAt":390},["ShallowReactive",2],{"blog-diagrams/session-lifecycle-and-the-jwt-gap":3},{"id":4,"title":5,"body":6,"category":361,"date":362,"dateModified":362,"description":363,"draft":364,"extension":365,"faq":366,"featured":364,"headerVariant":373,"image":374,"keywords":376,"meta":377,"navigation":153,"ogDescription":378,"ogTitle":379,"path":380,"readTime":381,"schemaOrg":382,"schemaType":383,"seo":384,"sitemap":385,"stem":386,"tags":387,"twitterCard":388,"__hash__":389},"blog/blog/diagrams/session-lifecycle-and-the-jwt-gap.md","The Session Lifecycle and the JWT Revocation Gap (Diagram)",{"type":7,"value":8,"toc":354},"minimark",[9,13,16,24,29,32,39,50,55,62,69,75,79,82,85,88,94,97,101,104,112,115,118,122,125,282,319,338,350],[10,11,12],"p",{},"A stateless JWT is valid because it says it is. Your server reads the signature and the expiry inside the token, decides it looks fine, and serves the request. No lookup, no database, no list of who is still allowed in.",[10,14,15],{},"That's the speed. It's also the problem, and this diagram is the shape of it.",[17,18],"diagram",{"alt":19,"caption":20,"height":21,"src":22,"width":23},"A state diagram of a login credential. From Anonymous, login moves it to a live Signed in state where a stolen copy also works. Two transitions leave that state: Revoked, reachable only for server-side sessions when you log out or ban an account, and Expired, which happens when the expiry time passes and is the only exit available to a stateless JWT.","Two ways out of the live state. A stateless JWT only has the right-hand one.",1204,"session-lifecycle-and-the-jwt-gap",848,[25,26,28],"h2",{"id":27},"reading-the-diagram","Reading the diagram",[10,30,31],{},"Read it top to bottom. Each box is a state the credential is in, and each arrow is a thing that moves it.",[10,33,34,38],{},[35,36,37],"strong",{},"The grey box is where everyone starts."," No credential exists yet, so there is nothing to steal.",[10,40,41,44,45,49],{},[35,42,43],{},"The red box is the entire security question."," Login hands out a credential with an expiry already written into it, and from that moment the credential is live. Note the third line: a stolen copy works here too. A credential does not know who is holding it. If someone copied the token out of a log, out of ",[46,47,48],"code",{},"localStorage",", off a shared laptop, their copy sits in exactly this same state as the legitimate one, with the same powers.",[10,51,52],{},[35,53,54],{},"Two arrows leave the red box, and they are not equivalent.",[10,56,57,58,61],{},"The left arrow, to ",[35,59,60],{},"Revoked",", is you making a decision. A user logs out, or you ban an account, or a password gets reset. The server writes down that this credential is finished, and the next request carrying it gets refused. That arrow requires the server to keep a record and check it, which is what \"server-side session\" means.",[10,63,64,65,68],{},"The right arrow, to ",[35,66,67],{},"Expired",", is not a decision. It's a clock running out. Nobody chose it, nobody can bring it forward, and it happens at exactly the time that was baked in at login.",[10,70,71,74],{},[35,72,73],{},"The bold label is the whole point."," For a stateless JWT, the left arrow does not exist. There is no record to write and nothing that checks one. The only way out of the live state is waiting.",[25,76,78],{"id":77},"what-that-means-in-practice","What that means in practice",[10,80,81],{},"A founder shipping a JWT-based login almost always believes logout works. It looks like it works: click the button, get bounced to the sign-in page, and every subsequent request fails.",[10,83,84],{},"What actually happened is that the browser deleted its own copy. That's a client-side change to a client-side thing. Nothing was communicated to the server, because with a stateless token there is nothing on the server to communicate with.",[10,86,87],{},"So the honest description of \"log out\" under a stateless JWT is: the credential is still valid, and you have politely stopped using it.",[89,90,91],"danger-box",{},[10,92,93],{},"This is the gap that turns a small incident into a long one. If a token leaks on a Monday and your expiry is 30 days, an attacker has until roughly the following month, and there is no button anywhere in your app that shortens that. Rotating a secret, resetting a password, and deleting the user account all leave the already-issued token working.",[10,95,96],{},"The same gap explains a bug report that confuses people: an admin removes someone's access, that person keeps working normally for hours, and everybody assumes the permission system is broken. It isn't. The permission change landed in the database. The token in that person's browser still asserts the old role, and nothing is reading the database to notice.",[25,98,100],{"id":99},"closing-the-gap","Closing the gap",[10,102,103],{},"There is no way to keep stateless verification and get revocation. You are choosing which one you want, and you can choose differently per route.",[10,105,106,107,111],{},"The usual resolution is to make the ",[108,109,110],"em",{},"live"," state short instead of trying to exit it early. Issue an access token measured in minutes, and pair it with a refresh token that is stored server-side and can be revoked. The access token still cannot be recalled, but the window where that matters shrinks from a month to the length of a coffee break, and the thing an attacker actually wants long-term access to, the refresh token, sits behind a check you control.",[10,113,114],{},"The other options are a denylist of revoked token IDs consulted on each request, or a version number per user that invalidates everything issued before a bump. Both work. Both mean a lookup, which means the token is no longer stateless, which is the tradeoff being made either way.",[10,116,117],{},"Pick deliberately. The failure mode is not choosing JWTs. It's choosing them for the speed and then assuming you also got the revocation.",[25,119,121],{"id":120},"the-source","The source",[10,123,124],{},"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.",[126,127,132],"pre",{"className":128,"code":129,"language":130,"meta":131,"style":131},"language-mermaid shiki shiki-themes github-dark","stateDiagram-v2\n    direction TB\n\n    state \"Anonymous.\u003Cbr/>No credential yet.\" as Anon\n    state \"Signed in.\u003Cbr/>The credential is live.\u003Cbr/>A stolen copy works here too.\" as Live\n    state \"Revoked.\u003Cbr/>The server refuses it.\" as Revoked\n    state \"Expired.\u003Cbr/>The clock refuses it.\" as Expired\n\n    [*] --> Anon\n    Anon --> Live: login issues a credential\u003Cbr/>with an expiry baked in\n\n    Live --> Revoked: you log out, or\u003Cbr/>you ban the account.\u003Cbr/>server-side sessions only\n    Live --> Expired: the expiry time passes.\u003Cbr/>\u003Cb>a stateless JWT\u003Cbr/>has no other exit\u003C/b>\n\n    Revoked --> [*]\n    Expired --> [*]\n\n    classDef neutral fill:#f5f5f4,stroke:#57534e,stroke-width:3px,color:#1c1917\n    classDef danger fill:#fef2f2,stroke:#ef4444,stroke-width:3px,color:#1c1917\n    classDef safe fill:#ecfdf5,stroke:#10b981,stroke-width:3px,color:#1c1917\n\n    class Anon neutral\n    class Live danger\n    class Revoked safe\n    class Expired safe\n","mermaid","",[46,133,134,142,148,155,161,167,173,179,184,190,196,201,207,213,218,224,230,235,241,247,253,258,264,270,276],{"__ignoreMap":131},[135,136,139],"span",{"class":137,"line":138},"line",1,[135,140,141],{},"stateDiagram-v2\n",[135,143,145],{"class":137,"line":144},2,[135,146,147],{},"    direction TB\n",[135,149,151],{"class":137,"line":150},3,[135,152,154],{"emptyLinePlaceholder":153},true,"\n",[135,156,158],{"class":137,"line":157},4,[135,159,160],{},"    state \"Anonymous.\u003Cbr/>No credential yet.\" as Anon\n",[135,162,164],{"class":137,"line":163},5,[135,165,166],{},"    state \"Signed in.\u003Cbr/>The credential is live.\u003Cbr/>A stolen copy works here too.\" as Live\n",[135,168,170],{"class":137,"line":169},6,[135,171,172],{},"    state \"Revoked.\u003Cbr/>The server refuses it.\" as Revoked\n",[135,174,176],{"class":137,"line":175},7,[135,177,178],{},"    state \"Expired.\u003Cbr/>The clock refuses it.\" as Expired\n",[135,180,182],{"class":137,"line":181},8,[135,183,154],{"emptyLinePlaceholder":153},[135,185,187],{"class":137,"line":186},9,[135,188,189],{},"    [*] --> Anon\n",[135,191,193],{"class":137,"line":192},10,[135,194,195],{},"    Anon --> Live: login issues a credential\u003Cbr/>with an expiry baked in\n",[135,197,199],{"class":137,"line":198},11,[135,200,154],{"emptyLinePlaceholder":153},[135,202,204],{"class":137,"line":203},12,[135,205,206],{},"    Live --> Revoked: you log out, or\u003Cbr/>you ban the account.\u003Cbr/>server-side sessions only\n",[135,208,210],{"class":137,"line":209},13,[135,211,212],{},"    Live --> Expired: the expiry time passes.\u003Cbr/>\u003Cb>a stateless JWT\u003Cbr/>has no other exit\u003C/b>\n",[135,214,216],{"class":137,"line":215},14,[135,217,154],{"emptyLinePlaceholder":153},[135,219,221],{"class":137,"line":220},15,[135,222,223],{},"    Revoked --> [*]\n",[135,225,227],{"class":137,"line":226},16,[135,228,229],{},"    Expired --> [*]\n",[135,231,233],{"class":137,"line":232},17,[135,234,154],{"emptyLinePlaceholder":153},[135,236,238],{"class":137,"line":237},18,[135,239,240],{},"    classDef neutral fill:#f5f5f4,stroke:#57534e,stroke-width:3px,color:#1c1917\n",[135,242,244],{"class":137,"line":243},19,[135,245,246],{},"    classDef danger fill:#fef2f2,stroke:#ef4444,stroke-width:3px,color:#1c1917\n",[135,248,250],{"class":137,"line":249},20,[135,251,252],{},"    classDef safe fill:#ecfdf5,stroke:#10b981,stroke-width:3px,color:#1c1917\n",[135,254,256],{"class":137,"line":255},21,[135,257,154],{"emptyLinePlaceholder":153},[135,259,261],{"class":137,"line":260},22,[135,262,263],{},"    class Anon neutral\n",[135,265,267],{"class":137,"line":266},23,[135,268,269],{},"    class Live danger\n",[135,271,273],{"class":137,"line":272},24,[135,274,275],{},"    class Revoked safe\n",[135,277,279],{"class":137,"line":278},25,[135,280,281],{},"    class Expired safe\n",[283,284,285,292,301,307,313],"faq-section",{},[286,287,289],"faq-item",{"question":288},"Why can't I just invalidate a JWT when someone logs out?",[10,290,291],{},"Because nothing is checking with you. A stateless JWT is verified by reading the signature and the expiry inside the token itself. Your server never looks anything up, which is the entire performance argument for using one. Logging out deletes the copy in that browser. It does not reach any other copy, and there is no list your server consults that you could remove it from.",[286,293,295],{"question":294},"What actually happens when a user clicks log out with a JWT?",[10,296,297,298,300],{},"The client throws its own token away. That is the whole operation. If the token was already copied, by malware, by a shared machine, by a leaked log line, or by anyone who read it out of ",[46,299,48],{},", that copy is untouched and keeps working until its expiry passes.",[286,302,304],{"question":303},"How do I get revocation back?",[10,305,306],{},"You add state, which means giving up the property that made the token stateless. The common approaches are a denylist of revoked token IDs checked on each request, short access tokens paired with a refresh token you can revoke server-side, or a token version number stored per user that invalidates every token issued before a bump. All three mean a lookup on requests that need to be revocable.",[286,308,310],{"question":309},"How short should my token expiry be?",[10,311,312],{},"Short enough that the window you cannot close is a window you can live with. With no revocation path, the expiry is your entire incident response, so a 30-day access token means a stolen token is usable for up to 30 days. Minutes for an access token, with a revocable refresh token behind it, is the usual shape.",[286,314,316],{"question":315},"Does this mean JWTs are a bad choice?",[10,317,318],{},"No. It means the tradeoff is real and worth making on purpose. Stateless verification is genuinely faster and scales across services without a shared session store. The mistake is picking a JWT for those benefits and then assuming logout, password reset, or banning an account takes effect immediately. Those need a revocation path that a stateless token does not have.",[320,321,322,328,333],"related-articles",{},[323,324],"related-card",{"description":325,"href":326,"title":327},"The full comparison this diagram illustrates one axis of","/blog/comparisons/session-vs-jwt","Sessions vs JWTs",[323,329],{"description":330,"href":331,"title":332},"Expiry, storage, signing and the settings that decide how bad a leak gets","/blog/how-to/jwt-security","How to Secure JWTs",[323,334],{"description":335,"href":336,"title":337},"Idle timeouts, absolute timeouts, and regenerating on a privilege change","/blog/how-to/session-management","How to Manage Sessions Securely",[339,340,343,347],"cta-box",{"href":341,"label":342},"/","Start Free Scan",[25,344,346],{"id":345},"is-your-token-sitting-somewhere-it-shouldnt","Is your token sitting somewhere it shouldn't?",[10,348,349],{},"A scan reads what your live app actually ships to the browser, including tokens left in client-side storage and endpoints that accept an expired one.",[351,352,353],"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":131,"searchDepth":144,"depth":144,"links":355},[356,357,358,359,360],{"id":27,"depth":144,"text":28},{"id":77,"depth":144,"text":78},{"id":99,"depth":144,"text":100},{"id":120,"depth":144,"text":121},{"id":345,"depth":144,"text":346},"diagrams","2026-08-06","A state diagram of a login session: a credential goes live, and it can leave that state two ways. A stateless JWT only has one of them.",false,"md",[367,368,370,371,372],{"question":288,"answer":291},{"question":294,"answer":369},"The client throws its own token away. That is the whole operation. If the token was already copied, by malware, by a shared machine, by a leaked log line, or by anyone who read it out of localStorage, that copy is untouched and keeps working until its expiry passes.",{"question":303,"answer":306},{"question":309,"answer":312},{"question":315,"answer":318},"blue",{"src":375,"alt":19},"https://checkyourvibe.dev/diagrams/session-lifecycle-and-the-jwt-gap.png","jwt cannot be revoked, session lifecycle diagram, jwt revocation problem, how to invalidate a jwt, logout does not invalidate jwt, stateless token revocation, session vs jwt revocation",{"ogImage":375},"Revocation is a transition. A stateless JWT does not have it.",null,"/blog/diagrams/session-lifecycle-and-the-jwt-gap","5 min read","[object Object]","Article",{"title":5,"description":363},{"loc":380},"blog/diagrams/session-lifecycle-and-the-jwt-gap",[],"summary_large_image","JQDwSB0EvHM8nxiG27A0dINPaaS0WQhX5xr-bTar9rw",1786039026830]