OAuth 2.0 defines how applications obtain authorization to access resources; OpenID Connect (OIDC) defines the representation and verification of user authentication results on top of it. An application can request only API authorization, or it can log in via OIDC and then establish its own session. JWT is a token representation format; it is neither a synonym for OAuth nor a promise of "statelessness."
Role
Example
Core Responsibility
Resource Owner
User who owns the document
Authorizes access; not all flows involve user participation
Client
Application requesting document access
Initiates authorization, maintains transactions, holds and uses tokens
Authorization Server
Organization's identity and authorization service
Authenticates, makes authorization decisions, issues and manages tokens
Resource Server
Document API
Validates tokens and enforces resource-level permissions
The authorization server and resource server may belong to the same product or be deployed independently. The browser primarily handles user interaction and redirection; in server-side web modes, the client that actually exchanges the authorization code and saves the token is typically the application backend. RFC 6749
Explaining Authorization Code + PKCE with a Single Login
Before starting the login, the application creates a short-lived transaction, recording the expected issuer, callback URL, random state, nonce, and PKCE verifier, and binds it to the browser session initiating the login. Since different tabs can log in concurrently, you cannot use a single global variable to save the "last state."
Parameters match registration and local transaction
PKCE Binding
code_challenge, code_challenge_method=S256
Verifier stays with client, challenge sent to authorization server
User Interaction
Login and applicable consent
Existing sessions may skip credential entry; not always a popup
Handle Callback
code or error, along with returned state
Find and verify initiating transaction; reject expired or unrelated responses
Exchange
code, verifier, applicable redirect_uri and client authentication
Authorization server verifies binding; authorization code is single-use
Establish Application Session
Validate OIDC ID Token, map to local account
Then create application's own session and permission context
The client must also associate error callbacks with the original transaction. Do not skip association checks just because error=access_denied was returned. Production implementations must also handle duplicate callbacks, lost code-exchange responses, and transaction expiration, rather than simply replaying the same authorization code.
OAuth security best practices require authorization servers to support PKCE; public clients must use PKCE; confidential clients should also use it. The implicit flow has not disappeared from all systems, but returning access tokens directly in the authorization response is no longer the standard choice for new implementations; the password grant should explicitly not be used. Machine identities, device interactions, etc., still have their own authorization methods; do not call the code flow the "only flow for all scenarios." RFC 9700 §2
How is the PKCE value calculated?
The S256 calculation is BASE64URL(SHA256(ASCII(code_verifier))), removing trailing = from Base64. The verifier length is 43–128 allowed characters, generated by a sufficiently random number; the challenge is not a hexadecimal string of SHA-256.
importbase64importhashlibimportsecretsdefchallenge(verifier):returnbase64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode('ascii')# Public vector from RFC 7636 Appendix B, not a secret used at runtime.
verifier='dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'expected='E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'assertchallenge(verifier)==expectedassertchallenge(verifier+'x')!=expectedfresh=secrets.token_urlsafe(32)assertlen(fresh)==43andlen(challenge(fresh))==43print('PKCE public vector and modification rejection passed')
An interceptor who obtains the code but lacks the corresponding verifier cannot complete the exchange using only the code. PKCE is not long-term client authentication: publicly distributed SPAs or native apps cannot rely on embedded client_secret for secrecy; server-side clients that can protect secrets still authenticate themselves according to registration. RFC 7636
What do state, nonce, and PKCE bind respectively?
Value
Associated Location
Relationship to Verify
state
Authorization request and browser callback
Does this callback belong to the transaction initiated by this browser?
nonce
OIDC request and ID Token
Is the authentication result in the token bound to the expected transaction?
PKCE
Authorization request and token exchange
Does the exchanger hold the verifier generated at that time?
The protective effects of the three overlap; it is incorrect to simply say "missing any one of them is definitely a violation in all protocol modes." This article's examples explicitly use all three to facilitate verifying association relationships separately. Especially in multi-issuer scenarios, prevent handing A's response to B's token endpoint: the expected issuer should be determined from trusted transactions and configuration, not just trusting any address given in the callback.
Why is the challenge not the hexadecimal of SHA256?
These two outputs come from the same 32-byte digest, but only the Base64url output is the challenge required by the protocol. Changing one allowed character changes the binding result; shortening the verifier to below 43 characters first violates syntax requirements. You cannot treat the hash of an arbitrary string as a valid PKCE.
Preparing the visual
Why is the challenge not the hexadecimal of SHA256?
The digest is 32 bytes; hex and Base64url are different encodings. Only obtaining the binding challenge according to S256 rules satisfies this exchange check.
{"id":"pkce-s256-bytes","title":"Why is the challenge not the hexadecimal of SHA256?","summary":"The digest is 32 bytes; hex and Base64url are different encodings. Only obtaining the binding challenge according to S256 rules satisfies this exchange check.","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><label class=\"form-field\"><span data-i18n=\"verifier\"></span><input type=\"text\" id=\"verifier\" value=\"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk\" maxlength=\"128\" autocomplete=\"off\" spellcheck=\"false\"></label><section class=\"panel\"><h3 data-i18n=\"digest\"></h3><div id=\"digest\"></div></section><section class=\"panel\"><h3 data-i18n=\"challenge\"></h3><div id=\"challenge\"></div></section><div class=\"metrics\"><div><span data-i18n=\"result\"></span><strong id=\"result\"></strong></div></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n#digest,#challenge{font:13px/1.8 var(--viz-font-mono);overflow-wrap:anywhere;min-height:5.4em}.metrics strong{font-size:18px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}const enc=s=>new TextEncoder().encode(s),cat=(...xs)=>Uint8Array.from(xs.flatMap(x=>[...x])),hex=x=>[...x].map(n=>n.toString(16).padStart(2,'0')).join('');async function sha(x){return new Uint8Array(await crypto.subtle.digest('SHA-256',x))}async function hmac(k,x){const key=await crypto.subtle.importKey('raw',k,{name:'HMAC',hash:'SHA-256'},false,['sign']);return new Uint8Array(await crypto.subtle.sign('HMAC',key,x))}async function prf(secret,label,seed,n){const s=cat(enc(label),seed);let a=s,out=new Uint8Array;while(out.length<n){a=await hmac(secret,a);out=cat(out,await hmac(secret,cat(a,s)))}return out.slice(0,n)}async function expand(secret,info,n){let prev=new Uint8Array,out=new Uint8Array;for(let i=1;out.length<n;i++){prev=await hmac(secret,cat(prev,info,[i]));out=cat(out,prev)}return out.slice(0,n)}async function label(secret,name,ctx,n){const l=enc('tls13 '+name),info=cat([n>>8,n&255,l.length],l,[ctx.length],ctx);return expand(secret,info,n)}async function derive(secret,name,messages){return label(secret,name,await sha(messages),32)}let epoch=0;async function draw(){const e=++epoch,v=$('#verifier').value,valid=/^[A-Za-z0-9._~-]{43,128}$/.test(v);$('#result').dataset.ready='false';set('result',t('busy'));const hash=await sha(enc(v)),b64=btoa(String.fromCharCode(...hash)).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');if(e!==epoch)return;set('digest',hex(hash));set('challenge',b64);const match=valid&&b64==='E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';set('result',!valid?'INVALID VERIFIER':match?'MATCH':'MISMATCH');$('#result').dataset.ready='true';$('#result').dataset.match=String(match);}$('#verifier').oninput=draw;draw();","audio":false,"strings":{"busy":"Computing","challenge":"Base64url (43 characters)","digest":"SHA256 hex (64 characters)","heading":"Why is the challenge not the hexadecimal of SHA256?","intro":"Edit the public test verifier, actually calculate SHA256 and unpadded Base64url, and compare the binding value during authorization.","reset":"Reset","result":"This binding check","scope":"RFC7636 public vector, not a secret; only verifies verifier syntax and S256 binding, does not implement code validity period, one-time consumption, client authentication, state, or OIDC.","scopeLabel":"Model Scope and Conventions","verifier":"Public Verifier"}}
ID Token, Access Token, and Refresh Token Are Not Interchangeable
The ID Token is for OIDC clients, stating the user's authentication result; its validity period does not equal the mandatory validity period of the application session, nor does it replace permission checks for each API. The Access Token is for resource servers; it can contain user information, and may also represent clients without interactive users. Whether it contains user claims depends on the token format and authorization scenario; do not infer from the name.
OIDC's scope=openid indicates a request for OIDC authentication; profile, email, etc., are requests for claims and do not guarantee that all fields will appear. email may change and is not inherently globally unique; an application's external identity key should be based on a combination of trusted iss and sub, followed by local account mapping. When calling UserInfo, verify that its sub matches the already validated ID Token. OIDC Core
Requesting offline_access does not mean the server unconditionally returns a refresh token; issuance depends on the flow, user consent, and provider policy. A token response without a refresh token is not necessarily a protocol error.
JWT Validation: Decide Who to Trust First, Then Choose the Key
Commonly signed JWTs use the JWS compact three-part form: Base64url(header), Base64url(payload), signature. Encoding is not encryption; anyone obtaining such a token can usually read the claims; encrypted JWTs use a different structure and cannot be summarized as "counting three dots."
kid is a hint for finding candidate keys in the trusted key set, not a certificate, nor an arbitrary key retrieval URL. Clients should obtain jwks_uri from the trusted issuer's discovery metadata and verify the issuer in the metadata; the JWKS path is not fixed to /.well-known/jwks.json. Do not arbitrarily access an issuer or jku based on an unverified token. OIDC Discovery
Validation Layer
Judgment Content
Cryptography and Format
Allowed algorithms, signatures, key usage, parsing, and duplicate field policies
Trust Source
Issuer must exactly match expected configuration
Recipient
ID Token's aud contains this client; Access Token is for the current API
Time
exp, applicable nbf, acceptable clock skew, and authentication freshness
Transaction Binding
If nonce was requested, verify its value; handle azp and other corresponding rules
Token Purpose
Use different validation rules for ID Token and Access Token
Business Authorization
scope, subject, tenant, resource ownership, and actions
During key rotation, you can refresh the cache for unknown kids to a limited extent, while avoiding triggering infinite external requests for every strange kid. The retention period for old public keys must cover old tokens that should still be accepted; deleting verification keys is not the same as letting tokens expire naturally. JWT BCP
JWT Access Tokens compliant with RFC 9068 use the at+jwt type and define corresponding claims and validation requirements; you cannot require all historical or private access tokens to automatically follow this profile, nor can you accept "any JWT with a correct signature" as API credentials. RFC 9068
The following only demonstrates business authorization after the trusted token has been verified, deliberately not treating the JSON dictionary as a complete token validator. Even if scope allows reading, you must check if the document belongs to the current tenant.
defmay_read(claims,document,now):return(claims['iss']=='https://identity.example'andclaims['aud']=='documents-api'andnow<claims['exp']and'documents:read'inclaims['scope'].split()andclaims['tenant_id']==document['tenant_id'])c=dict(iss='https://identity.example',aud='documents-api',exp=2000,scope='documents:read',tenant_id='tenant-a')assertmay_read(c,{'tenant_id':'tenant-a'},1999)assertnotmay_read(c,{'tenant_id':'tenant-b'},1999)assertnotmay_read(c,{'tenant_id':'tenant-a'},2000)assertnotmay_read(dict(c,aud='web-client'),{'tenant_id':'tenant-a'},1999)print('Cross-tenant, expired, and wrong recipient all rejected')
Refresh, Revocation, and Logout Are Three Different Things
After the Access Token expires, the client can use the refresh token to request a new token from the authorization server. If rotation is used, each refresh returns a new refresh token and invalidates the old one; the client needs to atomically save the new value and coordinate concurrent refreshes. Two requests using the old token simultaneously may cause one to be seen as a reuse, thereby revoking the entire token family.
Reuse detection also encounters the ambiguity of "server has rotated, but response was lost in the network." Do not retry the old value infinitely; the provider's recovery window, reuse policy, and client re-authentication path must be consistent. Refresh tokens for public clients should use sender constraints or rotation as specified replay protection; simply putting the token into client storage is not this mechanism. RFC 9700 §4.14
Operation
State Directly Changed
Not Automatically Guaranteed
Delete Application Session
This application's login status
All API tokens are invalidated
Revoke Refresh Token
Subsequent refresh capability and provider-related authorization status
Issued JWTs are immediately rejected at all APIs
Authorization Server Logout
Identity Provider session
Each application's local session disappears synchronously
Revoke Access Token
Validity in the authorization server
APIs relying only on offline signature verification are not instantly aware
If the API only checks JWT signature and expiration time, it may still accept it after revocation until expiration; for faster convergence, explicit solutions such as introspection, revocation status propagation, or shorter validity periods can be used. The active returned by introspection also depends on its context, and caching will cause visible delays. Token Introspection, Token Revocation
Bearer token holders can use it; DPoP binds the token to a proof key and generates a proof for the request, changing this usage condition. However, it does not replace scope or resource authorization, nor does it allow a fully controlled client to automatically regain trust. DPoP
Application Form Determines Where Tokens Are Stored
Form
Identity and Storage Method
Issues to Handle
Server-side Web / BFF
Backend exchanges and saves token, browser holds application session cookie
XSS, refresh coordination, recovery after page reload
Native App
Public client, uses system authorization interaction and PKCE
Redirect reception, system storage, process lifecycle
Backend Service
Use client credentials to express service identity when applicable
Credential protection, scope, rotation, and workload ownership
client credentials have no end-user login step; do not use a specific employee's identity to interpret its sub. In the base specification, this flow should not issue refresh tokens; services usually re-acquire access tokens using their own credentials. Application embedded secrets, OAuth authorization, and user authentication are three separate problems that need to be designed independently. RFC 6749 §4.4
When troubleshooting integration, first locate the stage: if the authorization request is rejected, check registration and parameters; for invalid_grant, check if the code is expired, used, or binding mismatch; for signature verification failure, check trusted issuer and keys; if login succeeds but API is rejected, check token type, audience, scope, and resource permissions. Do not use "can decode JWT" as the criterion for completion of login integration.