[{"data":1,"prerenderedAt":454},["ShallowReactive",2],{"writing-sso-oauth-jwt-what-each-one-does":3},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"updated":10,"tags":11,"draft":6,"body":15,"_type":448,"_id":449,"_source":450,"_file":451,"_stem":452,"_extension":453},"/writing/sso-oauth-jwt-what-each-one-does","writing",false,"","SSO, OAuth, and JWT: what each one actually does","These three get used interchangeably and they are not the same thing. What each layer is responsible for, and the mistakes that follow from confusing them.","2026-05-12",[12,13,14],"authentication","oauth","identity",{"type":16,"children":17,"toc":438},"root",[18,26,33,93,98,104,117,129,141,147,152,157,267,279,285,290,302,308,343,348,354,359,364,370],{"type":19,"tag":20,"props":21,"children":22},"element","p",{},[23],{"type":24,"value":25},"text","SSO, OAuth, and JWT get used as if they were three names for the same thing. They are not. One is a user experience, one is an authorization protocol, and one is a file format. Most authentication bugs I have reviewed trace back to that confusion.",{"type":19,"tag":27,"props":28,"children":30},"h2",{"id":29},"the-one-line-version",[31],{"type":24,"value":32},"The one-line version",{"type":19,"tag":34,"props":35,"children":36},"ul",{},[37,49,66,76],{"type":19,"tag":38,"props":39,"children":40},"li",{},[41,47],{"type":19,"tag":42,"props":43,"children":44},"strong",{},[45],{"type":24,"value":46},"SSO",{"type":24,"value":48}," is an outcome: sign in once, reach many applications.",{"type":19,"tag":38,"props":50,"children":51},{},[52,57,59,64],{"type":19,"tag":42,"props":53,"children":54},{},[55],{"type":24,"value":56},"OAuth 2.0",{"type":24,"value":58}," is a protocol for delegated ",{"type":19,"tag":42,"props":60,"children":61},{},[62],{"type":24,"value":63},"authorization",{"type":24,"value":65}," — getting an application permission to call an API on a user's behalf.",{"type":19,"tag":38,"props":67,"children":68},{},[69,74],{"type":19,"tag":42,"props":70,"children":71},{},[72],{"type":24,"value":73},"OIDC",{"type":24,"value":75}," is a thin authentication layer on top of OAuth 2.0. This is what actually tells you who the user is.",{"type":19,"tag":38,"props":77,"children":78},{},[79,84,86,91],{"type":19,"tag":42,"props":80,"children":81},{},[82],{"type":24,"value":83},"JWT",{"type":24,"value":85}," is a token ",{"type":19,"tag":42,"props":87,"children":88},{},[89],{"type":24,"value":90},"format",{"type":24,"value":92},". It is not a protocol and it grants nothing by itself.",{"type":19,"tag":20,"props":94,"children":95},{},[96],{"type":24,"value":97},"You can build SSO without JWTs. You can use JWTs with no SSO anywhere. They sit at different layers.",{"type":19,"tag":27,"props":99,"children":101},{"id":100},"oauth-is-about-access-not-identity",[102],{"type":24,"value":103},"OAuth is about access, not identity",{"type":19,"tag":20,"props":105,"children":106},{},[107,109,115],{"type":24,"value":108},"This is the distinction that causes real vulnerabilities. OAuth 2.0 was designed so that an application could get a scoped access token to call an API without ever handling the user's password. The access token says ",{"type":19,"tag":110,"props":111,"children":112},"em",{},[113],{"type":24,"value":114},"this bearer may call these APIs with this scope",{"type":24,"value":116},". It says nothing reliable about who the user is.",{"type":19,"tag":20,"props":118,"children":119},{},[120,122,127],{"type":24,"value":121},"The classic mistake is treating a successful OAuth flow as proof of identity: the app receives an access token, calls a userinfo endpoint, gets back an ID, and logs the user in as that ID. The problem is that an access token is a bearer credential. If an attacker obtains a token issued to a ",{"type":19,"tag":110,"props":123,"children":124},{},[125],{"type":24,"value":126},"different",{"type":24,"value":128}," application and presents it, a naive server may accept it and authenticate them as the token's owner. This is the confused-deputy shape behind a whole family of \"sign in with X\" bugs.",{"type":19,"tag":20,"props":130,"children":131},{},[132,134,139],{"type":24,"value":133},"OpenID Connect exists precisely to fix this. It adds an ",{"type":19,"tag":42,"props":135,"children":136},{},[137],{"type":24,"value":138},"ID token",{"type":24,"value":140}," — a JWT with a defined set of claims, issued to a specific client, that you validate rather than merely possess. If you are doing authentication, you want OIDC and an ID token, not raw OAuth and a userinfo call.",{"type":19,"tag":27,"props":142,"children":144},{"id":143},"what-to-validate-in-a-jwt",[145],{"type":24,"value":146},"What to validate in a JWT",{"type":19,"tag":20,"props":148,"children":149},{},[150],{"type":24,"value":151},"A JWT is three base64url segments: header, payload, signature. Anyone can read the payload. Decoding is not verifying, and the gap between those two verbs is where the bugs live.",{"type":19,"tag":20,"props":153,"children":154},{},[155],{"type":24,"value":156},"At minimum, verify:",{"type":19,"tag":34,"props":158,"children":159},{},[160,170,193,207,228,253],{"type":19,"tag":38,"props":161,"children":162},{},[163,168],{"type":19,"tag":42,"props":164,"children":165},{},[166],{"type":24,"value":167},"Signature",{"type":24,"value":169},", against a key you fetched from the issuer's JWKS endpoint.",{"type":19,"tag":38,"props":171,"children":172},{},[173,183,185,191],{"type":19,"tag":42,"props":174,"children":175},{},[176],{"type":19,"tag":177,"props":178,"children":180},"code",{"className":179},[],[181],{"type":24,"value":182},"alg",{"type":24,"value":184}," — against an allowlist you control. Never take the algorithm from the token header. The ",{"type":19,"tag":177,"props":186,"children":188},{"className":187},[],[189],{"type":24,"value":190},"alg: none",{"type":24,"value":192}," attack and the RS256-to-HS256 confusion attack both work by letting the token choose how it is checked.",{"type":19,"tag":38,"props":194,"children":195},{},[196,205],{"type":19,"tag":42,"props":197,"children":198},{},[199],{"type":19,"tag":177,"props":200,"children":202},{"className":201},[],[203],{"type":24,"value":204},"iss",{"type":24,"value":206}," — the issuer is who you expect.",{"type":19,"tag":38,"props":208,"children":209},{},[210,219,221,226],{"type":19,"tag":42,"props":211,"children":212},{},[213],{"type":19,"tag":177,"props":214,"children":216},{"className":215},[],[217],{"type":24,"value":218},"aud",{"type":24,"value":220}," — the audience is ",{"type":19,"tag":110,"props":222,"children":223},{},[224],{"type":24,"value":225},"your",{"type":24,"value":227}," client. This is the check that stops a token minted for another application being replayed at yours.",{"type":19,"tag":38,"props":229,"children":230},{},[231,240,242,251],{"type":19,"tag":42,"props":232,"children":233},{},[234],{"type":19,"tag":177,"props":235,"children":237},{"className":236},[],[238],{"type":24,"value":239},"exp",{"type":24,"value":241}," and ",{"type":19,"tag":42,"props":243,"children":244},{},[245],{"type":19,"tag":177,"props":246,"children":248},{"className":247},[],[249],{"type":24,"value":250},"nbf",{"type":24,"value":252}," — it is currently valid, with only small clock skew allowed.",{"type":19,"tag":38,"props":254,"children":255},{},[256,265],{"type":19,"tag":42,"props":257,"children":258},{},[259],{"type":19,"tag":177,"props":260,"children":262},{"className":261},[],[263],{"type":24,"value":264},"nonce",{"type":24,"value":266},", for OIDC ID tokens, matching the value you sent.",{"type":19,"tag":20,"props":268,"children":269},{},[270,272,277],{"type":24,"value":271},"Skipping ",{"type":19,"tag":177,"props":273,"children":275},{"className":274},[],[276],{"type":24,"value":218},{"type":24,"value":278}," is the single most common omission I see, and it is the one that turns a token leak somewhere else into an account takeover on your system.",{"type":19,"tag":27,"props":280,"children":282},{"id":281},"jwts-are-hard-to-revoke",[283],{"type":24,"value":284},"JWTs are hard to revoke",{"type":19,"tag":20,"props":286,"children":287},{},[288],{"type":24,"value":289},"A signed token is valid until it expires because validation is offline by design — that is the whole appeal. It is also the drawback. If you use long-lived JWTs as your session, then logout, password change, role removal, and account suspension do not take effect until expiry.",{"type":19,"tag":20,"props":291,"children":292},{},[293,295,300],{"type":24,"value":294},"The usual resolution is boring and correct: short-lived access tokens, minutes not days, plus a long-lived refresh token held server-side that you ",{"type":19,"tag":110,"props":296,"children":297},{},[298],{"type":24,"value":299},"can",{"type":24,"value":301}," revoke. If you genuinely need instant revocation on the access token itself, you need a check against server state, at which point you have given up the stateless property and should be honest about that trade rather than pretending you still have it.",{"type":19,"tag":27,"props":303,"children":305},{"id":304},"where-to-put-the-token-in-a-browser",[306],{"type":24,"value":307},"Where to put the token in a browser",{"type":19,"tag":20,"props":309,"children":310},{},[311,317,319,325,327,333,335,341],{"type":19,"tag":177,"props":312,"children":314},{"className":313},[],[315],{"type":24,"value":316},"localStorage",{"type":24,"value":318}," is readable by any JavaScript running on the page, which means one XSS is one token theft. A cookie with ",{"type":19,"tag":177,"props":320,"children":322},{"className":321},[],[323],{"type":24,"value":324},"HttpOnly",{"type":24,"value":326},", ",{"type":19,"tag":177,"props":328,"children":330},{"className":329},[],[331],{"type":24,"value":332},"Secure",{"type":24,"value":334},", and ",{"type":19,"tag":177,"props":336,"children":338},{"className":337},[],[339],{"type":24,"value":340},"SameSite",{"type":24,"value":342}," is not reachable from script and is the better default for browser sessions — with CSRF protection, since cookies are sent automatically.",{"type":19,"tag":20,"props":344,"children":345},{},[346],{"type":24,"value":347},"The often-repeated advice to avoid cookies \"because CSRF\" trades a well-understood, well-tooled problem for a worse one. XSS-stealable credentials are harder to defend than CSRF.",{"type":19,"tag":27,"props":349,"children":351},{"id":350},"saml-has-not-gone-away",[352],{"type":24,"value":353},"SAML has not gone away",{"type":19,"tag":20,"props":355,"children":356},{},[357],{"type":24,"value":358},"In enterprise SSO you will still meet SAML 2.0 constantly. Signed XML assertions, an identity provider, a service provider. It predates OAuth and it is not going anywhere.",{"type":19,"tag":20,"props":360,"children":361},{},[362],{"type":24,"value":363},"The security-relevant thing to know is that SAML's use of XML signatures is genuinely difficult to implement safely — XML signature wrapping attacks, canonicalisation subtleties, comment-truncation bugs in assertion parsing. Use a maintained library. Do not hand-roll assertion validation, and be sceptical of any code that parses the assertion separately from verifying it.",{"type":19,"tag":27,"props":365,"children":367},{"id":366},"a-short-checklist",[368],{"type":24,"value":369},"A short checklist",{"type":19,"tag":34,"props":371,"children":372},{},[373,378,409,414,433],{"type":19,"tag":38,"props":374,"children":375},{},[376],{"type":24,"value":377},"Doing authentication? Use OIDC and validate the ID token. Not raw OAuth.",{"type":19,"tag":38,"props":379,"children":380},{},[381,383,388,389,394,395,400,402,407],{"type":24,"value":382},"Validate ",{"type":19,"tag":177,"props":384,"children":386},{"className":385},[],[387],{"type":24,"value":204},{"type":24,"value":326},{"type":19,"tag":177,"props":390,"children":392},{"className":391},[],[393],{"type":24,"value":218},{"type":24,"value":326},{"type":19,"tag":177,"props":396,"children":398},{"className":397},[],[399],{"type":24,"value":239},{"type":24,"value":401},", and the signature, with a server-side ",{"type":19,"tag":177,"props":403,"children":405},{"className":404},[],[406],{"type":24,"value":182},{"type":24,"value":408}," allowlist.",{"type":19,"tag":38,"props":410,"children":411},{},[412],{"type":24,"value":413},"Keep access tokens short-lived; keep revocable state server-side.",{"type":19,"tag":38,"props":415,"children":416},{},[417,419,424,426,431],{"type":24,"value":418},"Prefer ",{"type":19,"tag":177,"props":420,"children":422},{"className":421},[],[423],{"type":24,"value":324},{"type":24,"value":425}," cookies over ",{"type":19,"tag":177,"props":427,"children":429},{"className":428},[],[430],{"type":24,"value":316},{"type":24,"value":432}," for browser sessions.",{"type":19,"tag":38,"props":434,"children":435},{},[436],{"type":24,"value":437},"Never accept a token without checking it was minted for you.",{"title":7,"searchDepth":439,"depth":439,"links":440},2,[441,442,443,444,445,446,447],{"id":29,"depth":439,"text":32},{"id":100,"depth":439,"text":103},{"id":143,"depth":439,"text":146},{"id":281,"depth":439,"text":284},{"id":304,"depth":439,"text":307},{"id":350,"depth":439,"text":353},{"id":366,"depth":439,"text":369},"markdown","content:writing:sso-oauth-jwt-what-each-one-does.md","content","writing/sso-oauth-jwt-what-each-one-does.md","writing/sso-oauth-jwt-what-each-one-does","md",1785577357166]