------------------------------------------------------------------ == PKCE parameters (RFC 7636) ------------------------------------------------------------------ code_verifier D6kPMLmsNKTof_0_UEga6cyBnpBSX5UkfU6eUgWFZW3BJ4iU9OVB5xvNk0hPQsW3 (64 chars) code_challenge 0E5PYxr7XERt0s3OvrJY-HsaIhqh7JcFqJIjb8KnZjg code_challenge_method S256 The verifier never leaves the client until the token request. The challenge is all the authorization request carries, and it is a one-way hash of the verifier. ------------------------------------------------------------------ == 1. Log in to the authorization server (browser session) ------------------------------------------------------------------ $ curl -c jar -d username=alice -d password=password -d _csrf= http://localhost:9000/login HTTP/1.1 302 Location: http://localhost:9000/ ------------------------------------------------------------------ == 2. GET /oauth2/authorize (client=demo-web) ------------------------------------------------------------------ $ curl -b jar 'http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-web&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Flogin%2Foauth2%2Fcode%2Fdemo-web&scope=openid%20orders.read%20orders.write&state=xyz123&code_challenge=0E5PYxr7XERt0s3OvrJY-HsaIhqh7JcFqJIjb8KnZjg&code_challenge_method=S256' -> 302 http://localhost:9000/oauth2/consent?scope=orders.write%20openid%20orders.read&client_id=demo-web&state=FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24%3D ------------------------------------------------------------------ == 3. The consent page ------------------------------------------------------------------ The authorization endpoint redirected to OUR page, at the path given to .consentPage("/oauth2/consent"). Note the query string it hands over: http://localhost:9000/oauth2/consent?scope=orders.write%20openid%20orders.read client_id=demo-web state=FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24%3D Scopes rendered as checkboxes (openid deliberately not among them): orders.write orders.read The hidden state the form must echo back: FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24= (this is NOT the client's state=xyz123 - it is the server's own correlation handle for the pending authorization request, and sending the client's value instead is what produces the consent redirect loop) ------------------------------------------------------------------ == 4. POST the approval to /oauth2/authorize ------------------------------------------------------------------ $ curl -b jar -X POST -d client_id=demo-web -d state=FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24= -d _csrf=P_NC_egjNFLN-1IOgLWK3N9qIgIvnhma_LGeHCLVKyZKXF1SB5J7zdgXUGHgzmU7tZi-7epcDztK-y-3ndStLBSwSRR5Pm5j -d scope=orders.write -d scope=orders.read http://localhost:9000/oauth2/authorize -> 302 http://127.0.0.1:8080/login/oauth2/code/demo-web?code=7K02csgk4cAepvRDnCiDqNA9gOVLCSGnjU-ByFlWBaHdFxe1byEXN14iQ3UOAMn_rWnY_jUz3xWAeeYke2UA8G74BcAhgzlmaRkxIAs6e6MywPQz-6eJ6H5XB6okDZVT&state=xyz123 ------------------------------------------------------------------ == 5. The authorization code ------------------------------------------------------------------ code = 7K02csgk4cAepvRDnCiDqNA9gOVLCSGnjU-ByFlWBaHdFxe1byEXN14iQ3UOAMn_rWnY_jUz3xWAeeYke2UA8G74BcAhgzlmaRkxIAs6e6MywPQz-6eJ6H5XB6okDZVT state = xyz123 (the client's own value, returned untouched - compare it) ------------------------------------------------------------------ == 6a. Exchange the code WITHOUT the verifier ------------------------------------------------------------------ This is the request an attacker who stole the code can make. HTTP 400 { "error": "invalid_grant" } >>> Rejected. invalid_grant is deliberately vague: the server will not tell >>> a caller whether the code was wrong, expired, already used, or missing a >>> verifier, because each of those is information an attacker can use. Note: this consumed the code. Authorization codes are single-use, so the successful exchange below needs a fresh one. ------------------------------------------------------------------ == 6b. A fresh code, exchanged properly ------------------------------------------------------------------ fresh code = dR8FdRofdCXW63nskaWklVZAALCEFP4eG8kNmo6KctU7_KUBzxyPenduDcuFwP3ek7ohYsf5Mff7zVbfkyzPaYhC-k-x9hAlteWsQNFkmZO6xZQyaCMaUp-H3DLPn4M3 $ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token HTTP 200 { "access_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3MTUsInNjb3BlIjpbIm9yZGVycy53cml0ZSIsIm9wZW5pZCIsIm9yZGVycy5yZWFkIl0sInJvbGVzIjpbIkFETUlOIiwiVVNFUiJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwMTUsImlhdCI6MTc4NzUzODcxNSwianRpIjoiYjQxNzljNDYtZTA1NS00YjY0LWJhNTktZjE1ZjkyYTkyY2QxIiwidGVuYW50IjoiYWNtZSJ9.LdqBUotxR3briYnuL56ZuFHKmKCr1hehZtzO-7sMa9sclA30jTsvn2sadm-kj9vnk0HRJP0WyR2UX7NGl1quIGlUlSN_0YqV4gTYbfKSIhAV3JilNeXXgSIe2X04UjjnesOxp2Ui-Umk5v3zpZqeSM0ZJtTafDyBXMNA_I3n5CzJ1_AiYFQ9DxvfN0pQCN-hik2gP1a_u9l1sSD0r_Su8YCtReYue37wA2tGgviA1mRMM4xaDOCSyGmie41kq4Bj0K9a8bOCSHEZ9CGnjmVTQENF3ZhkPjX_EnkDvzLcBZh84DtEue6ddp5jdWH5DSJTk8YKipvNqL6eK3CGjX4Byw", "refresh_token": "8BsQgRSdo4MCHf2sZb9-paqL0tOhYGYOfpYxdJ659LUefpK3csiabvBV5JyNaE9PZuweNEADbNuTtbfHIVtinxyaoH871wqd3YXTVHOQCMZ46_FUM9CkicHeRU17TL_E", "scope": "orders.write openid orders.read", "id_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8td2ViIiwiYXpwIjoiZGVtby13ZWIiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3MTQsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsInByZWZlcnJlZF91c2VybmFtZSI6ImFsaWNlIiwiZXhwIjoxNzg3NTQwNTE1LCJpYXQiOjE3ODc1Mzg3MTUsImp0aSI6IjQwZmJjMGEwLTMyNTQtNDFkMC04M2Q1LTJlOTBhZjcxZmY2MyIsInNpZCI6Ilk0Zk5vZjQ3bldodlFMSjB4Z3lRX3BPbTdfVERxTXAyMm1tamg0Mjl2Sm8ifQ.BmuLT6VfjSP_EUPSLKwnkGVAThNHDv-9Z0uRnlGNC39nJL2P8SbgDYB7pWxy0eLQ8mqi4iwyoL9faFLFGaqAvndaPqLSSiWmJ5L4PfCGEW7PTa0vaRjgO-MHtFdAvlUfltWzm39nskyj0Q94QNNv5p7ZW7NAwBFwRL9IFzPzxi80IivvdLwgPZEHOKc6DLAmtrpTOgTkucOIwW_FF3FWbOJ4XgqT5dApqdon74ikq_8ZcwotqhVlkv2Z1VfiSLj4OBh9t35McLah4UszvAl8aYQlEkY6xrzoal8bWqpaJOMJs709-cd13NCv58WV45BbVMakphc_jk0XM7rXIhC45w", "token_type": "Bearer", "expires_in": 299 } ------------------------------------------------------------------ == 7. The access token ------------------------------------------------------------------ { "alg": "RS256", "kid": "262bd550-3657-46c4-bafc-cce4c6f4e0cb" } { "aud": "orders-api", "exp": 1787539015, "iat": 1787538715, "iss": "http://localhost:9000", "jti": "b4179c46-e055-4b64-ba59-f15f92a92cd1", "nbf": 1787538715, "roles": [ "ADMIN", "USER" ], "scope": [ "orders.write", "openid", "orders.read" ], "sub": "alice", "tenant": "acme" } ------------------------------------------------------------------ == 8. The id_token - a different token, for a different audience ------------------------------------------------------------------ { "aud": "demo-web", "auth_time": 1787538714, "azp": "demo-web", "exp": 1787540515, "iat": 1787538715, "iss": "http://localhost:9000", "jti": "40fbc0a0-3254-41d0-83d5-2e90af71ff63", "preferred_username": "alice", "sid": "Y4fNof47nWhvQLJ0xgyQ_pOm7_TDqMp22mmjh429vJo", "sub": "alice" } aud is the CLIENT here, not the API. Sending this to a resource server is the classic mix-up: it verifies (same issuer, same key) and then fails the audience check, or worse, passes it if nobody checks audience. ------------------------------------------------------------------ == 9. Calling the resource server ------------------------------------------------------------------ GET /api/orders -> 200 {"orders":[{"total":"42.00","id":1}],"subject":"alice","clientId":null,"scopes":["orders.write","openid","orders.read"],"roles":["ADMIN","USER"],"tenant":"acme","audience":["orders-api"]} GET /api/admin -> 200 {"authorities":["FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T02:31:55.262560965Z]","SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN","SCOPE_orders.write"],"message":"admin only"} ------------------------------------------------------------------ == 10. Sending the id_token instead ------------------------------------------------------------------ HTTP/1.1 401 WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the required audience orders-api is missing", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource" ------------------------------------------------------------------ == 11. Refresh, with rotation ------------------------------------------------------------------ old refresh token: 8BsQgRSdo4MCHf2sZb9-paqL... new refresh token: aPYpv9v2EcgMOzCvQdhMLycP... DIFFERENT - reuseRefreshTokens(false), the old one is now dead Replaying the old one: {"error":"invalid_grant"}