Chapter V10 OAuth and OIDC of the OWASP Application Security Verification Standard has five Level 1 requirements, and none of them are about your application. Every one is an obligation of the authorization server: the thing that shows the login page and hands out tokens. If you use Auth0, Okta, Entra ID, or Keycloak, these five are things you configure and then verify, not things you code.
That is why this part breaks the pattern of the series. There is no Node.js version and no Go version, because there is nothing here for either of them to do. Instead you will run a real authorization server, Keycloak, and drive it with curl until each requirement either holds or visibly fails.
Part 11 covered what your application must check about a token it receives. This part covers how that token is allowed to be issued in the first place.
Conceptual Overview
OAuth has three parties. The resource owner is the human. The client is your application, which wants a token. The authorization server issues tokens after the human logs in. OpenID Connect (OIDC) is a thin layer on top of OAuth that adds an identity token, so the client learns who logged in and not just that something was approved.
The authorization code flow is a two-step handshake, on purpose. The browser is sent to the authorization server. After login, the server redirects back to the client with a short random string called an authorization code. The client then calls the token endpoint from its own backend, sends the code plus its client secret, and gets tokens back. The code travels through the browser where it can leak. The tokens never do.
Every requirement in this chapter protects that code or the tokens it buys. Where does the code get sent (V10.4.1), how many times can it be spent (V10.4.2), how long does it live (V10.4.3), which flows are allowed at all (V10.4.4), and what happens when a refresh token is stolen and replayed (V10.4.5).
A public client cannot keep a secret. A single page application or a mobile app ships its code to the user, so anything embedded in it is public. That is why V10.4.5 singles out public clients: they cannot prove who they are at the token endpoint, so a stolen refresh token is as good as the real client.
Defaults are not compliance. Keycloak gets three of these five right out of the box. You still have to check, because a colleague widening a setting to unblock a demo is exactly how these end up wrong, and the check takes one command.
Prerequisites
- Ubuntu 24.04 LTS with
sudoaccess. - Docker, from Getting Started with Docker and Docker Compose on Ubuntu. Keycloak runs on the Java Virtual Machine, and a container keeps that off your host.
curlandpython3, both already on Ubuntu.- Ports
8080and8081free. - Part 11, since the tokens issued here are the ones your application has to verify.
- Somewhere to record what you close. Part 1 lists all 70 Level 1 requirements.
Step 1: Run an Authorization Server
docker run -d --name kc -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.7.2 start-dev
start-dev runs Keycloak with an in-memory database and without HTTPS. That is right for this article and wrong for anything else. A production deployment needs start, a real database, and TLS, and Keycloak refuses to start in production mode without them.
Wait for it to answer, which takes about half a minute on a first run:
until curl -sf http://localhost:8080/realms/master > /dev/null; do sleep 1; done
echo "keycloak is up"
Every administrative command below goes through kcadm.sh inside the container. Define a shortcut so the commands stay readable:
kcadm() { docker exec kc /opt/keycloak/bin/kcadm.sh "$@"; }
kcadm config credentials --server http://localhost:8080 \
--realm master --user admin --password admin
Logging into http://localhost:8080 as user admin of realm master
Step 2: Create a Realm, a User, and a Client
A realm is an isolated set of users, clients, and settings. Never put your application in the master realm, which exists to administer Keycloak itself.
kcadm create realms -s realm=invoices -s enabled=true
kcadm create users -r invoices -s username=alice -s email=[email protected] \
-s firstName=Alice -s lastName=Nurhayati -s enabled=true
kcadm set-password -r invoices --username alice \
--new-password 'correct horse battery staple'
Created new realm with id 'invoices'
Created new user with id 'e8d1a3e3-d33e-4fba-a49c-75f238c81749'
Now the client, deliberately misconfigured so there is something to fix. The redirect URI ends in *, and two flows are switched on that the application does not use:
kcadm create clients -r invoices \
-s clientId=invoice-web -s enabled=true \
-s publicClient=false -s secret=s3cr3t-for-testing-only \
-s 'redirectUris=["http://localhost:8081/*"]' \
-s standardFlowEnabled=true \
-s implicitFlowEnabled=true \
-s directAccessGrantsEnabled=true
Created new client with id 'a10541be-f472-494b-aaf8-a97d4ba20789'
Keep the internal identifier handy, because updates address the client by it and not by clientId:
CID=$(kcadm get clients -r invoices -q clientId=invoice-web \
--fields id --format csv --noquotes)
Finally, a script that plays the part of the browser: it opens the login page, submits Alice’s password, and prints the redirect Keycloak answers with. Create getcode.sh:
#!/bin/bash
REDIRECT="$1"
BASE=http://localhost:8080/realms/invoices/protocol/openid-connect
rm -f cookies.txt
LOGIN_PAGE=$(curl -s -c cookies.txt \
"$BASE/auth?client_id=invoice-web&response_type=code&scope=openid&redirect_uri=$REDIRECT")
ACTION=$(echo "$LOGIN_PAGE" | grep -o 'action="[^"]*"' | head -1 \
| sed 's/action="//;s/"$//' | sed 's/&/\&/g')
if [ -z "$ACTION" ]; then echo "no login form, the server refused the request"; exit 1; fi
curl -s -b cookies.txt -o /dev/null -D - -X POST "$ACTION" \
-d 'username=alice' -d 'password=correct horse battery staple' \
| grep -i '^location:' | tr -d '\r'
chmod +x getcode.sh
./getcode.sh 'http://localhost:8081/callback'
Location: http://localhost:8081/callback?session_state=8qJkj2dnzQBWS0icUykVTE5w&iss=http%3A%2F%2Flocalhost%3A8080%2Frealms%2Finvoices&code=6a94aff6-c058-5cd2-7d5a-df3535957425.8qJkj2dnzQBWS0icUykVTE5w.a10541be-f472-494b-aaf8-a97d4ba20789
That code= value is what the rest of the article attacks.
Step 3: Match Redirect URIs Exactly
V10.4.1 Verify that the authorization server validates redirect URIs based on a client-specific allowlist of pre-registered URIs using exact string comparison.
The redirect URI decides where the authorization code is delivered. A wildcard means the client is telling the authorization server “any path on this host is fine”, and any path includes ones the attacker controls: a page that reflects a parameter, an uploaded HTML file, an old subdirectory nobody maintains.
Ask for a path that does not exist in the application:
./getcode.sh 'http://localhost:8081/attacker/steal'
With http://localhost:8081/* registered, Keycloak delivers the code there:
Location: http://localhost:8081/attacker/steal?session_state=s8PJOZAxQWGIzRzcCnhqJpVp&iss=http%3A%2F%2Flocalhost%3A8080%2Frealms%2Finvoices&code=304a223a-08e0-3319-9994-085f42fef987.s8PJOZAxQWGIzRzcCnhqJpVp.a10541be-f472-494b-aaf8-a97d4ba20789
Register the one URI the application actually uses:
kcadm update clients/$CID -r invoices \
-s 'redirectUris=["http://localhost:8081/callback"]'
Ask again, and the request never even reaches a login page:
curl -s "http://localhost:8080/realms/invoices/protocol/openid-connect/auth?client_id=invoice-web&response_type=code&redirect_uri=http://localhost:8081/attacker/steal" \
| grep -oE 'Invalid parameter[^<]*'
Invalid parameter: redirect_uri
Notice that Keycloak shows this error on its own page instead of redirecting to report it. That is correct behaviour, and it is the reason exact matching works at all: an authorization server that redirected to an unregistered URI to deliver an error message would be an open redirect.
List every URI on every client and read them:
kcadm get clients -r invoices --fields clientId,redirectUris
Any entry containing * fails this requirement. So does http://localhost:8081 without a path, since that is a different string from what your callback actually is.
Step 4: Burn the Authorization Code After One Use
V10.4.2 Verify that, if the authorization server returns the authorization code in the authorization response, it can be used only once for a token request. For the second valid request with an authorization code that has already been used to issue an access token, the authorization server must reject a token request and revoke any issued tokens related to the authorization code.
Read the second sentence carefully, because it asks for two things. Rejecting the second exchange is the obvious half. Revoking the tokens from the first exchange is the half people miss, and it is the half that matters: a second exchange means the code leaked, so the tokens someone already holds are suspect.
Create reuse.sh:
#!/bin/bash
BASE=http://localhost:8080/realms/invoices/protocol/openid-connect
CODE=$(./getcode.sh 'http://localhost:8081/callback' | grep -o 'code=[^&]*' | cut -d= -f2)
exchange() {
curl -s -X POST "$BASE/token" \
-d grant_type=authorization_code -d code="$CODE" \
-d redirect_uri=http://localhost:8081/callback \
-d client_id=invoice-web -d client_secret=s3cr3t-for-testing-only
}
AT=$(exchange | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
echo "userinfo before : $(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $AT" $BASE/userinfo)"
echo "second exchange : $(exchange)"
sleep 1
echo "userinfo after : $(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $AT" $BASE/userinfo)"
chmod +x reuse.sh
./reuse.sh
userinfo before : 200
second exchange : {"error":"invalid_grant","error_description":"Code not valid"}
userinfo after : 401
Both halves hold. The access token worked, the replayed code was refused, and then the access token stopped working without anybody logging out. Keycloak does this by default, so this requirement needs a test rather than a setting, and the test above belongs in your integration suite.
Step 5: Keep the Code Short-Lived
V10.4.3 Verify that the authorization code is short-lived. The maximum lifetime can be up to 10 minutes for L1 and L2 applications and up to 1 minute for L3 applications.
The code sits in a browser redirect, which means it can end up in browser history, a Referer header, or a proxy log. A short life limits how long a copy stays useful.
kcadm get realms/invoices --fields accessCodeLifespan
{
"accessCodeLifespan" : 60
}
Sixty seconds, comfortably inside the ten minute ceiling, and already inside the one minute Level 3 ceiling. Nothing to change. What is worth doing is proving the expiry actually fires, because a setting nobody tests is a setting nobody knows about. Shorten it, wait, and try to spend the code:
kcadm update realms/invoices -s accessCodeLifespan=5
CODE=$(./getcode.sh 'http://localhost:8081/callback' | grep -o 'code=[^&]*' | cut -d= -f2)
sleep 8
curl -s -X POST http://localhost:8080/realms/invoices/protocol/openid-connect/token \
-d grant_type=authorization_code -d code="$CODE" \
-d redirect_uri=http://localhost:8081/callback \
-d client_id=invoice-web -d client_secret=s3cr3t-for-testing-only
{"error":"invalid_grant","error_description":"Code not valid"}
Put it back:
kcadm update realms/invoices -s accessCodeLifespan=60
Do not confuse accessCodeLifespan with accessCodeLifespanLogin, which is thirty minutes and controls how long a user may sit on the login page before their attempt goes stale. Only the first one is the authorization code.
Step 6: Turn Off the Grants You Do Not Use
V10.4.4 Verify that for a given client, the authorization server only allows the usage of grants that this client needs to use. Note that the grants ‘token’ (Implicit flow) and ‘password’ (Resource Owner Password Credentials flow) must no longer be used.
The client created in Step 2 has three flows enabled and uses one. The other two are the two the requirement names.
The implicit flow returns tokens straight in the redirect, in the part of the URL after the #, so the access token lands in browser history and possibly in a Referer header. It existed because browsers could not make cross-origin requests to the token endpoint. They can now.
The password grant has the application collect the user’s password and post it to the authorization server. It defeats the point of OAuth, teaches users to type their password into anything that asks, and cannot support multi-factor authentication or a federated login.
Both are live right now:
curl -s -X POST http://localhost:8080/realms/invoices/protocol/openid-connect/token \
-d grant_type=password -d username=alice \
-d 'password=correct horse battery staple' \
-d client_id=invoice-web -d client_secret=s3cr3t-for-testing-only | head -c 60
{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lk
Turn off everything the application does not use. serviceAccountsEnabled is the client credentials grant, which a browser application never needs:
kcadm update clients/$CID -r invoices \
-s implicitFlowEnabled=false \
-s directAccessGrantsEnabled=false \
-s serviceAccountsEnabled=false
The password grant is gone:
{"error":"unauthorized_client","error_description":"Client not allowed for direct access grants"}
So is the implicit flow. Ask for response_type=token and Keycloak refuses before showing a login page:
curl -s -o /dev/null -D - "http://localhost:8080/realms/invoices/protocol/openid-connect/auth?client_id=invoice-web&response_type=token&redirect_uri=http://localhost:8081/callback" \
| grep -i '^location:'
Location: http://localhost:8081/callback#error=unauthorized_client&error_description=Client+is+not+allowed+to+initiate+browser+login+with+given+response_type.+Implicit+flow+is+disabled+for+the+client.
Check that the flow you do use still works:
./getcode.sh 'http://localhost:8081/callback' | grep -o 'code=[^&]*' | cut -c1-20
code=ecc27ed4-1fc6-6
Run this audit across every client, and question anything with more than one flow enabled:
kcadm get clients -r invoices \
--fields clientId,standardFlowEnabled,implicitFlowEnabled,directAccessGrantsEnabled
Step 7: Rotate Refresh Tokens and Punish Replay
V10.4.5 Verify that the authorization server mitigates refresh token replay attacks for public clients, preferably using sender-constrained refresh tokens, i.e., Demonstrating Proof of Possession (DPoP) or Certificate-Bound Access Tokens using mutual TLS (mTLS). For L1 and L2 applications, refresh token rotation may be used. If refresh token rotation is used, the authorization server must invalidate the refresh token after usage, and revoke all refresh tokens for that authorization if an already used and invalidated refresh token is provided.
A refresh token is long-lived by design, so a stolen one is worth far more than a stolen access token. The requirement offers a strong answer (bind the token to a key or a client certificate, so holding the token is not enough) and an answer that is good enough at Level 1: rotation. Every refresh returns a new refresh token and kills the old one. That does not stop the theft, but it makes it noisy, because sooner or later the thief and the real user both present a token that has already been spent.
Create refresh.sh:
#!/bin/bash
BASE=http://localhost:8080/realms/invoices/protocol/openid-connect
CODE=$(./getcode.sh 'http://localhost:8081/callback' | grep -o 'code=[^&]*' | cut -d= -f2)
get() { python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("'"$1"'", d.get("error_description","ok")))'; }
RT1=$(curl -s -X POST "$BASE/token" -d grant_type=authorization_code -d code="$CODE" \
-d redirect_uri=http://localhost:8081/callback \
-d client_id=invoice-web -d client_secret=s3cr3t-for-testing-only | get refresh_token)
refresh() {
curl -s -X POST "$BASE/token" -d grant_type=refresh_token -d refresh_token="$1" \
-d client_id=invoice-web -d client_secret=s3cr3t-for-testing-only
}
RT2=$(refresh "$RT1" | get refresh_token)
echo "replay RT1 : $(refresh "$RT1" | get nothing)"
echo "RT2 after replay : $(refresh "$RT2" | get nothing)"
Run it against the default settings first:
chmod +x refresh.sh
./refresh.sh
replay RT1 : ok
RT2 after replay : ok
The old refresh token still works after being used. An attacker who copied it once keeps a working session for as long as the token lives, and nothing anywhere records that two parties are using it.
Turn rotation on. revokeRefreshToken invalidates a refresh token once it is spent, and refreshTokenMaxReuse=0 means zero reuses are tolerated:
kcadm update realms/invoices -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
./refresh.sh
replay RT1 : Maximum allowed refresh token reuse exceeded
RT2 after replay : Session doesn't have required client
Both halves of the requirement are visible in those two lines. The replayed token was refused, and the currently valid token RT2 was revoked as well, because the authorization server cannot tell which of the two callers is the thief. Whoever is real logs in again; whoever is not is locked out.
Common Mistakes and Troubleshooting
Registering a redirect URI with a wildcard “just for the staging environment”. Register the staging URI as a second exact entry, or use a separate client. One * in one environment usually gets copied into production.
Treating the code exchange test as unnecessary because the vendor says so. Vendors change defaults between versions and administrators change settings between deployments. reuse.sh takes two seconds in continuous integration and it tests what you actually deployed.
Turning the password grant back on for a mobile app or a test suite. Mobile applications use the authorization code flow with a system browser and PKCE. Test suites can use a dedicated client with the client credentials grant, or a fixture session.
Rotation without the revoke-all rule. Some servers rotate refresh tokens but simply reject the old one. That stops one replay and leaves the thief’s newer token working. The requirement asks for the whole authorization to be revoked, which is what refreshTokenMaxReuse=0 gives you here.
no login form, the server refused the request from getcode.sh. The authorization server rejected the request before rendering a page, which is almost always the redirect URI not matching exactly. Ask for the URL directly with curl and read the error on the page.
Locking yourself out of a long-lived session while testing. Every replay test revokes the session. Run ./getcode.sh again to start a fresh one instead of wondering why the next command fails.
Best Practices
Use PKCE on every client, not only public ones. Proof Key for Code Exchange makes a stolen authorization code useless without a secret the browser generated. It is not required at Level 1, and it is the single strongest thing you can add here. Set -s 'attributes={"pkce.code.challenge.method":"S256"}' on the client.
Keep one client per application and per environment. Shared clients force the union of everyone’s redirect URIs and everyone’s grants onto one configuration.
Export the realm and keep it in version control. kcadm get realms/invoices gives you a reviewable file, so a widened setting shows up in a diff instead of in an incident.
Keep access tokens short and let refresh do the work. Five to fifteen minutes for an access token, rotation on the refresh token, and a session idle timeout that matches how the application is actually used.
Alarm on the reuse error. Maximum allowed refresh token reuse exceeded in the logs is one of the few signals that means “a token was probably stolen”. Send it somewhere a human reads.
Never run start-dev outside your laptop. It disables HTTPS enforcement and keeps everything in memory. Production means start, a database, a real hostname, and TLS.
Conclusion
Chapter V10 is five requirements that live entirely in your authorization server’s configuration. You narrowed the redirect URI from a wildcard to one exact string, proved that an authorization code cannot be spent twice and that the tokens it bought die when someone tries, confirmed the code expires in under a minute, removed the implicit and password grants, and turned on refresh token rotation with revocation on replay.
Two of those, the single-use code and the code lifetime, were already correct before you touched anything. Refresh token rotation was genuinely off by default, and the wildcard redirect and the spare grants were configuration a person had chosen. That mix is why the requirements are written as “verify that” rather than “configure”: the work is checking, and the checks in this article are all short enough to keep in continuous integration. Mark V10.4.1 through V10.4.5 as passed in your own record.
Clean up when you are done:
docker rm -f kc
Part 13 covers chapter V11 Cryptography, and returns to Node.js and Go: which ciphers, modes, and hash functions you are allowed to use, and what goes wrong with the ones you are not.
Requirement text quoted from the OWASP Application Security Verification Standard 5.0.0, used under CC BY-SA 4.0.