[{"data":1,"prerenderedAt":389},["ShallowReactive",2],{"blog-diagrams/pkce-who-holds-the-secret":3},{"id":4,"title":5,"body":6,"category":356,"date":357,"dateModified":357,"description":358,"draft":359,"extension":360,"faq":361,"featured":359,"headerVariant":372,"image":373,"keywords":375,"meta":376,"navigation":180,"ogDescription":377,"ogTitle":378,"path":379,"readTime":380,"schemaOrg":381,"schemaType":382,"seo":383,"sitemap":384,"stem":385,"tags":386,"twitterCard":387,"__hash__":388},"blog/blog/diagrams/pkce-who-holds-the-secret.md","PKCE: Who Holds the Secret at Each Hop (Diagram)",{"type":7,"value":8,"toc":350},"minimark",[9,18,21,30,35,38,45,51,60,71,74,87,91,101,104,108,111,269,316,334,346],[10,11,12,13,17],"p",{},"The authorization code that a login provider hands back travels through the browser. That means it can end up in a redirect chain, a proxy log, a browser history entry, or a ",[14,15,16],"code",{},"Referer"," header sent to whatever analytics script you installed last month.",[10,19,20],{},"PKCE assumes that code will leak, and makes it worthless anyway.",[22,23],"diagram",{"alt":24,"caption":25,"height":26,"href":27,"src":28,"width":29},"A sequence diagram of a PKCE login. Your server keeps a random verifier in the session and sends only its hash to the login provider, so an attacker who intercepts the one-time login code is rejected when they present it without the verifier, while your server completes the exchange by sending the code together with the verifier it kept.","The code crosses the open web. The verifier never does.",1358,"/blog/how-to/oauth-setup","pkce-who-holds-the-secret",2168,[31,32,34],"h2",{"id":33},"reading-the-diagram","Reading the diagram",[10,36,37],{},"Four parties, and the whole lesson is which of them ever holds the verifier.",[10,39,40,44],{},[41,42,43],"strong",{},"Step 1."," Your server invents a random string, the verifier, and keeps it in the session. What it sends to the browser is only the SHA-256 hash of that string, called the challenge. This is the move that matters. A hash goes one way, so anyone who sees the challenge still cannot produce the verifier.",[10,46,47,50],{},[41,48,49],{},"Steps 2 and 3."," The browser carries the challenge to the login provider, and the provider hands back a one-time login code. Both of those hops happen over the open web, through a browser you don't control.",[10,52,53,56,57,59],{},[41,54,55],{},"Steps 4 through 6"," are the red band, and they're the attack. The code leaks somehow: a redirect that logged its query string, a ",[14,58,16],{}," header, a shared machine's history. The attacker presents that code to the provider and gets nothing, because they can't supply the verifier that hashes to the challenge the provider stored at step 2. That rejection is the entire return on implementing PKCE.",[10,61,62,65,66,70],{},[41,63,64],{},"Steps 7 through 9"," are the legitimate exchange. The browser hands the code to your server, your server sends the code ",[67,68,69],"em",{},"plus"," the verifier it has been holding all along, the provider hashes the verifier and compares, and only then issues a token.",[10,72,73],{},"The attacker and your server present the same code. One of them also has the verifier. That's the only difference, and it's enough.",[75,76,77],"warning-box",{},[10,78,79,82,83,86],{},[41,80,81],{},"The verifier has to stay somewhere the browser can't read."," Putting it in ",[14,84,85],{},"localStorage"," or a readable cookie so the client can \"finish the flow\" removes the reason any of this works. Anyone who can read the code from the browser can then read the verifier from the same browser, and you're back to an interceptable login with extra steps. Keep it in the server session, keyed to that one attempt, and delete it after the exchange.",[31,88,90],{"id":89},"what-pkce-does-not-do","What PKCE does not do",[10,92,93,94,97,98,100],{},"It doesn't stop an attacker from starting a login flow and tricking you into completing it. That's CSRF against your callback route, and the ",[14,95,96],{},"state"," parameter is what handles it. The two get implemented together and mixed up constantly, so it's worth being blunt: PKCE protects the code, ",[14,99,96],{}," protects the session that asked for it. Dropping either leaves a real hole.",[10,102,103],{},"It also doesn't help once the token exists. After step 9 you're holding an access token, and everything about how you store and expire it is a separate problem.",[31,105,107],{"id":106},"the-mermaid-source","The Mermaid source",[10,109,110],{},"Copy this and adapt it to your provider.",[112,113,118],"pre",{"className":114,"code":115,"language":116,"meta":117,"style":117},"language-mermaid shiki shiki-themes github-dark","---\ntitle: \"A stolen code is useless without the verifier\"\n---\nsequenceDiagram\n    autonumber\n    participant S as Your server\n    participant B as Your app\u003Cbr/>in the browser\n    participant P as The login\u003Cbr/>provider\n    participant A as An attacker who\u003Cbr/>grabs the code\n\n    S->>B: redirect carrying only the hash.\u003Cbr/>the random verifier stays in the session\n    B->>P: the hash, over the open web\n    P-->>B: a one-time login code\n\n    rect rgb(254, 242, 242)\n        B-->>A: the code leaks through a redirect,\u003Cbr/>a log, or a referrer header\n        A->>P: the code, with no verifier\n        P-->>A: rejected. no verifier, no token.\n    end\n\n    B->>S: the code\n    S->>P: the code plus the verifier\u003Cbr/>it kept all along\n    P-->>S: access token\n\n    Note over S,A: the code crossed the open web.\u003Cbr/>the verifier never did.\n","mermaid","",[14,119,120,128,134,139,145,151,157,163,169,175,182,188,194,200,205,211,217,223,229,235,240,246,252,258,263],{"__ignoreMap":117},[121,122,125],"span",{"class":123,"line":124},"line",1,[121,126,127],{},"---\n",[121,129,131],{"class":123,"line":130},2,[121,132,133],{},"title: \"A stolen code is useless without the verifier\"\n",[121,135,137],{"class":123,"line":136},3,[121,138,127],{},[121,140,142],{"class":123,"line":141},4,[121,143,144],{},"sequenceDiagram\n",[121,146,148],{"class":123,"line":147},5,[121,149,150],{},"    autonumber\n",[121,152,154],{"class":123,"line":153},6,[121,155,156],{},"    participant S as Your server\n",[121,158,160],{"class":123,"line":159},7,[121,161,162],{},"    participant B as Your app\u003Cbr/>in the browser\n",[121,164,166],{"class":123,"line":165},8,[121,167,168],{},"    participant P as The login\u003Cbr/>provider\n",[121,170,172],{"class":123,"line":171},9,[121,173,174],{},"    participant A as An attacker who\u003Cbr/>grabs the code\n",[121,176,178],{"class":123,"line":177},10,[121,179,181],{"emptyLinePlaceholder":180},true,"\n",[121,183,185],{"class":123,"line":184},11,[121,186,187],{},"    S->>B: redirect carrying only the hash.\u003Cbr/>the random verifier stays in the session\n",[121,189,191],{"class":123,"line":190},12,[121,192,193],{},"    B->>P: the hash, over the open web\n",[121,195,197],{"class":123,"line":196},13,[121,198,199],{},"    P-->>B: a one-time login code\n",[121,201,203],{"class":123,"line":202},14,[121,204,181],{"emptyLinePlaceholder":180},[121,206,208],{"class":123,"line":207},15,[121,209,210],{},"    rect rgb(254, 242, 242)\n",[121,212,214],{"class":123,"line":213},16,[121,215,216],{},"        B-->>A: the code leaks through a redirect,\u003Cbr/>a log, or a referrer header\n",[121,218,220],{"class":123,"line":219},17,[121,221,222],{},"        A->>P: the code, with no verifier\n",[121,224,226],{"class":123,"line":225},18,[121,227,228],{},"        P-->>A: rejected. no verifier, no token.\n",[121,230,232],{"class":123,"line":231},19,[121,233,234],{},"    end\n",[121,236,238],{"class":123,"line":237},20,[121,239,181],{"emptyLinePlaceholder":180},[121,241,243],{"class":123,"line":242},21,[121,244,245],{},"    B->>S: the code\n",[121,247,249],{"class":123,"line":248},22,[121,250,251],{},"    S->>P: the code plus the verifier\u003Cbr/>it kept all along\n",[121,253,255],{"class":123,"line":254},23,[121,256,257],{},"    P-->>S: access token\n",[121,259,261],{"class":123,"line":260},24,[121,262,181],{"emptyLinePlaceholder":180},[121,264,266],{"class":123,"line":265},25,[121,267,268],{},"    Note over S,A: the code crossed the open web.\u003Cbr/>the verifier never did.\n",[270,271,272,282,288,297,307],"faq-section",{},[273,274,276],"faq-item",{"question":275},"What does PKCE actually protect against?",[10,277,278,279,281],{},"One thing: someone stealing the authorization code in transit and redeeming it themselves. That code travels back through the browser, so it can end up in a redirect chain, a server log, a history entry, or a ",[14,280,16],{}," header sent to a third party. PKCE makes a stolen code useless on its own, because redeeming it also requires the verifier, which never left your server.",[273,283,285],{"question":284},"What is the difference between the verifier and the challenge?",[10,286,287],{},"The verifier is a random string your server generates and keeps. The challenge is its SHA-256 hash, and it's the only one of the two that travels to the provider at the start. You can't work backwards from a hash, so an attacker who sees the challenge can't produce the verifier. At the end your server sends the verifier, the provider hashes it, and it either matches what it stored or the exchange fails.",[273,289,291],{"question":290},"Do I still need the state parameter if I use PKCE?",[10,292,293,294,296],{},"Yes, and they're not interchangeable. PKCE stops a stolen code from being redeemed by someone else. The ",[14,295,96],{}," parameter stops an attacker from tricking your app into completing a login flow it never started. Implement both.",[273,298,300],{"question":299},"Do I need PKCE if my app has a backend and a client secret?",[10,301,302,303,306],{},"Current OAuth guidance says to use it for all clients. A client secret proves ",[67,304,305],{},"which application"," is redeeming the code, not that it's the same session that started the flow. PKCE is what ties the redemption back to the browser session that began it.",[273,308,310],{"question":309},"Where should the verifier be stored?",[10,311,312,313,315],{},"In the server-side session, keyed to that single login attempt, and deleted once the exchange completes. Not ",[14,314,85],{},", not a readable cookie, not a URL. Anywhere the browser can read it is somewhere an attacker reading that browser can read it too.",[317,318,319,324,329],"related-articles",{},[320,321],"related-card",{"description":322,"href":27,"title":323},"The full implementation this diagram illustrates, including the state parameter and redirect URI matching.","How to Set Up OAuth Securely",[320,325],{"description":326,"href":327,"title":328},"What the state parameter is defending against, and why PKCE does not cover it.","/blog/vulnerabilities/csrf","CSRF Explained",[320,330],{"description":331,"href":332,"title":333},"The same custody problem in a different shape: a token that travels out of band and has to be single-use.","/blog/how-to/magic-links","How to Build Secure Magic Links",[335,336,339,343],"cta-box",{"href":337,"label":338},"/","Start Free Scan",[31,340,342],{"id":341},"is-your-login-flow-actually-using-pkce","Is your login flow actually using PKCE?",[10,344,345],{},"A scan checks your deployed app's auth configuration and flags the OAuth mistakes that show up most in AI-generated code.",[347,348,349],"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":117,"searchDepth":130,"depth":130,"links":351},[352,353,354,355],{"id":33,"depth":130,"text":34},{"id":89,"depth":130,"text":90},{"id":106,"depth":130,"text":107},{"id":341,"depth":130,"text":342},"diagrams","2026-08-12","A sequence diagram of the PKCE login round trip, showing which party holds the verifier and why an intercepted authorization code is worthless without it.",false,"md",[362,364,366,368,370],{"question":275,"answer":363},"One thing: someone stealing the authorization code in transit and redeeming it themselves. That code travels back through the browser, which means it can end up in a redirect chain, a server log, a browser history entry, or a Referer header sent to a third party. PKCE makes a stolen code useless on its own, because redeeming it also requires the verifier, which never left your server.",{"question":284,"answer":365},"The verifier is a random string your server generates and keeps. The challenge is its SHA-256 hash, and it's the only one of the two that travels to the login provider at the start. Because you cannot work backwards from a hash to the original string, an attacker who sees the challenge cannot produce the verifier. At the end your server sends the verifier, the provider hashes it, and it either matches the challenge it stored or the exchange fails.",{"question":290,"answer":367},"Yes. They solve different problems. PKCE stops a stolen code from being redeemed by someone else. The state parameter stops an attacker from tricking your app into completing a login flow it never started, which is CSRF against your callback. Neither one covers the other, so implement both.",{"question":299,"answer":369},"Current OAuth guidance says yes, use it for all clients. The client secret proves which application is redeeming the code, not that it is the same session that started the flow. PKCE is what ties the redemption to the browser session that began it, so it adds a protection the secret does not provide.",{"question":309,"answer":371},"In the server-side session, keyed to that one login attempt, and deleted once the exchange completes. Do not put it in localStorage, a non-httpOnly cookie, or a URL. Anywhere the browser can read it is somewhere an attacker who can read the browser can read it too, which removes the reason PKCE works.","blue",{"src":374,"alt":24},"https://checkyourvibe.dev/diagrams/pkce-who-holds-the-secret.png","pkce diagram, pkce flow explained, code verifier vs code challenge, oauth authorization code interception, pkce sequence diagram, what does pkce protect against",{"ogImage":374},"The code crosses the open web. The verifier never does. That gap is the whole of PKCE.",null,"/blog/diagrams/pkce-who-holds-the-secret","4 min read","[object Object]","Article",{"title":5,"description":358},{"loc":379},"blog/diagrams/pkce-who-holds-the-secret",[],"summary_large_image","VzPirCCMchZRKVKc8v7le7jP7kaX3Zbm0vt9F6eAdhc",1787602613816]