The spec is clear. The code review missed it anyway.

Check your access logs right now. Search for any request where access_token appears as a query parameter. If you find one, you have already confirmed flaws two and five in this list. The token is in your server logs. The scope is probably wider than it needs to be.
OAuth 2.0 is not a new protocol. RFC 6749 is from 2012. The Bearer token spec (RFC 6750) followed that same year. Twelve years of deployment experience later, the IETF published RFC 9700 in January 2025. It documents the attacks people have been getting hit by in the meantime. The same five patterns appear in production systems I have reviewed across Laravel, Python FastAPI and Go services. They pass code review because they look correct. The auth flow works. The token is issued. The tests pass.
The vulnerability is in what happens when something goes wrong.
1. Redirect URI Validation Is Too Loose
Here is the attack. An attacker sends a user to your authorization endpoint with a crafted redirect_uri pointing at their server. Your validation checks that the domain matches. It does. What it does not check is the path, because you registered https://yourapp.com and the request has https://yourapp.com.evil.io/callback or https://yourapp.com/redirect?url=https://evil.io.
The second variant is the open redirector. Your app has a legitimate /redirect endpoint that takes a destination URL and bounces the user there. Maybe for post-login flows. Maybe for deep links. The authorization code lands at your server. Your open redirector bounces it to the attacker. You never even knew it left.
RFC 9700 (Section 2.1) is unambiguous: authorization servers and clients “MUST utilize exact string matching” when comparing redirect URIs against pre-registered values. And: clients and authorization servers “MUST NOT expose URLs that forward the user’s browser to arbitrary URIs obtained from a query parameter.”
Vulnerable pattern (Python):
# Partial domain match - DO NOT DO THIS
def validate_redirect_uri(requested_uri, registered_uri):
from urllib.parse import urlparse
requested = urlparse(requested_uri)
registered = urlparse(registered_uri)
# Only checking scheme and netloc, path is ignored
return requested.scheme == registered.scheme and \
requested.netloc == registered.netlocCorrect pattern across stacks:
# Python: exact match only
def validate_redirect_uri(requested_uri, registered_uris):
return requested_uri in registered_uris// Laravel: exact match against registered list
public function validateRedirectUri(string $requestedUri, array $registeredUris): bool
{
return in_array($requestedUri, $registeredUris, strict: true);
}// Go: exact match, no prefix or wildcard logic
func validateRedirectURI(requestedURI string, registeredURIs []string) bool {
for _, uri := range registeredURIs {
if uri == requestedURI {
return true
}
}
return false
}The registered URI list lives in your database. Exact string match. No prefix matching, no wildcard domains, no “close enough.” If your mobile app needs multiple redirect URIs, register each one explicitly.
While you are there: audit every endpoint your application exposes that takes a URL parameter and performs a redirect. Open redirectors are almost always a forgotten feature, not a deliberate vulnerability.
2. PKCE Missing on Your Server-Side App
Most teams know PKCE is required for single-page apps and mobile clients. Fewer teams know that RFC 9700 (Section 2.1.1), published January 2025, explicitly RECOMMENDS it for confidential clients too. That includes the server-side apps where your client secret is safely stored on the server.
The attack PKCE prevents is authorization code interception. The authorization code in the callback URL can be stolen: by a malicious browser extension, by a compromised redirect target, by a log entry captured at the wrong moment. Without PKCE, whoever holds that code can exchange it for tokens at your authorization server. PKCE turns the code into a one-time credential: the client that generated the code_verifier is the only one who can complete the exchange.
RFC 9700 Section 2.1.1 states: “Public clients MUST use PKCE [RFC7636]. For confidential clients, the use of PKCE is RECOMMENDED, as it provides strong protection against misuse and injection of authorization codes.”
Implementation is straightforward. Before initiating the auth flow:
# Python: generate verifier and S256 challenge
import hashlib
import base64
import secrets
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b'=').decode()
# Store code_verifier in session, send code_challenge in auth request
# auth_url = f"...&code_challenge={code_challenge}&code_challenge_method=S256"// Laravel: generate verifier and S256 challenge
$codeVerifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$codeChallenge = rtrim(strtr(
base64_encode(hash('sha256', $codeVerifier, true)),
'+/', '-_'
), '=');
// Store $codeVerifier in session, send $codeChallenge in auth request
// $authUrl = "...&code_challenge={$codeChallenge}&code_challenge_method=S256";op// Go: generate verifier and S256 challenge
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
)
func generatePKCE() (verifier, challenge string) {
b := make([]byte, 32)
rand.Read(b)
verifier = base64.RawURLEncoding.EncodeToString(b)
h := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(h[:])
return
}When the authorization code comes back, you send the code_verifier to complete the exchange. The authorization server hashes it and verifies it matches the original challenge. An intercepted code is useless without the verifier.
One detail that trips people up: the plain method for code challenge is allowed by the RFC but deprecated in security guidance. Use S256 exclusively.
3. Refresh Tokens Without Rotation
This one is about the token your user never sees. The access token expires in fifteen minutes. The refresh token you issued to that mobile client twelve months ago still works. The user changed their password twice since then. Their device was replaced. You have no idea if that refresh token is still in the hands of the original user.
Refresh tokens “represent the full scope of access granted to a certain client,” per RFC 9700 Section 4.14.1. They are not constrained to a specific resource. If an attacker exfiltrates a refresh token, they can mint new access tokens indefinitely.
RFC 9700 Section 2.2.2 requires: “Refresh tokens for public clients MUST be sender-constrained or use refresh token rotation.”
Rotation works like this. When a client uses a refresh token to get a new access token, the authorization server issues a new refresh token and invalidates the old one. Each refresh token is single-use. If your server receives a refresh token that has already been used, that is a replay attack. The right response is not to issue tokens. The right response is to revoke the entire grant for that user and client combination, and require the user to authenticate again.
That last part is what most implementations skip. They implement rotation but not replay detection. Without it, an attacker who steals a refresh token can use it once before the legitimate client does, and the legitimate client just gets a new token from their next use. The attacker is silently authorized.
Rotation with replay detection:
# Python pseudocode, verify against your token store implementation
def refresh_tokens(incoming_refresh_token, user_id, client_id):
token_record = db.get_refresh_token(incoming_refresh_token)
if token_record is None:
if db.was_previously_issued(incoming_refresh_token, user_id, client_id):
db.revoke_all_tokens_for_grant(user_id, client_id)
raise SecurityError("Refresh token replay detected, grant revoked")
raise InvalidTokenError("Unknown refresh token")
db.invalidate_refresh_token(incoming_refresh_token)
new_access_token = generate_access_token(user_id, client_id)
new_refresh_token = generate_refresh_token(user_id, client_id)
db.store_refresh_token(new_refresh_token, user_id, client_id)
return new_access_token, new_refresh_token// Laravel pseudocode, adapt to your token store
public function refreshTokens(string $incoming, int $userId, string $clientId): array
{
$record = DB::table('refresh_tokens')->where('token', $incoming)->first();
if (!$record) {
$wasIssued = DB::table('refresh_token_history')
->where('token', $incoming)
->where('user_id', $userId)
->where('client_id', $clientId)
->exists();
if ($wasIssued) {
DB::table('refresh_tokens')
->where('user_id', $userId)
->where('client_id', $clientId)
->delete();
abort(401, 'Refresh token replay detected, grant revoked');
}
abort(401, 'Unknown refresh token');
}
DB::table('refresh_tokens')->where('token', $incoming)->delete();
DB::table('refresh_token_history')->insert([
'token' => $incoming, 'user_id' => $userId, 'client_id' => $clientId,
]);
return [
'access_token' => $this->generateAccessToken($userId, $clientId),
'refresh_token' => $this->generateRefreshToken($userId, $clientId),
];
}// Go pseudocode, adapt to your token store
func (s *TokenService) RefreshTokens(ctx context.Context, incoming, userID, clientID string) (access, refresh string, err error) {
_, err = s.db.GetRefreshToken(ctx, incoming)
if err != nil {
wasIssued, _ := s.db.WasPreviouslyIssued(ctx, incoming, userID, clientID)
if wasIssued {
s.db.RevokeAllTokensForGrant(ctx, userID, clientID)
return "", "", ErrReplayDetected
}
return "", "", ErrUnknownToken
}
s.db.InvalidateRefreshToken(ctx, incoming)
access = s.generateAccessToken(userID, clientID)
refresh = s.generateRefreshToken(userID, clientID)
s.db.StoreRefreshToken(ctx, refresh, userID, clientID)
return access, refresh, nil
}The key move is storing a reference to previously-issued tokens long enough to detect replays. How long depends on your token lifetime, but the principle holds regardless of stack.
4. Access Tokens as URL Query Parameters
This is the mistake you can audit right now. Run this against your logs:
grep -E "access_token=|token=[A-Za-z0-9._-]{20,}" /var/log/nginx/access.log | head -20If that returns results, your access tokens are in your web server logs. They are in your CDN logs. They are in any proxy sitting between your client and your server. Every Referer header sent from those pages includes the token in the URL. Browser history stores it. Bookmark syncing transmits it.
RFC 6750 (Section 2.3) has been explicit about this since 2012: bearer tokens “SHOULD NOT be passed in page URLs (for example, as query string parameters).” The spec notes: “Browsers, web servers, and other software may not adequately secure URLs in the browser history, web server logs, and other data structures.”
The correct way to transmit an access token is in the Authorization header:
GET /api/v1/resource HTTP/1.1
Host: api.yourapp.com
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...In code, this looks like:
# Python (requests library)
import requests
headers = {"Authorization": f"Bearer {access_token}"}
response = requests.get("https://api.yourapp.com/v1/resource", headers=headers)// Laravel (Http facade)
use Illuminate\Support\Facades\Http;
$response = Http::withToken($accessToken)
->get('https://api.yourapp.com/v1/resource');// Go (net/http)
req, _ := http.NewRequest("GET", "https://api.yourapp.com/v1/resource", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, _ := client.Do(req)This does not appear in access logs by default. It does not appear in Referer headers. It does not get bookmarked.
The pattern shows up most often in: webhook callbacks where a token is passed back to a third-party URL, legacy integrations that were “quick to set up,” mobile deep links that carry auth state and PDF or export endpoints where someone put ?token= in the URL because it was convenient for the S3 presigned URL equivalent.
Audit your API surface for any route that accepts an access_token query parameter. For webhooks, use a shared secret in a header instead. For exports, generate a short-lived signed URL or use a one-time download token that does not grant full API access.
5. Access Tokens With Too Much Scope
The last flaw is not about how the token moves. It is about what it can do.
The pattern looks like this: your authorization server issues a single access token type for your API. That token can read and write user data, manage billing, access administrative endpoints and query internal health checks. The scope is * or admin:all or nothing at all. The token is just a bearer credential and every endpoint trusts it equally.
RFC 9700 Section 2.3 requires that “the privileges associated with an access token SHOULD be restricted to the minimum required for the particular application or use case” and that “access tokens SHOULD be audience-restricted to a specific resource server.”
The blast radius of a leaked token is proportional to its scope. A token scoped to read:profile leaks a name and email address. A token scoped to admin:all leaks everything and writes everything until someone notices.
Scope restriction in practice:
Audience restriction: the aud claim in the JWT specifies which resource server(s) accept the token. A token issued for api.yourapp.com/billing should be rejected by api.yourapp.com/admin. This requires your resource servers to validate the aud claim on every request, which many implementations skip.
Scope per action: instead of read:users, define read:users:profile and read:users:email separately. Clients request only the scopes they actually use. This is more work upfront and pays for itself when the first token is leaked.
Short expiry for high-privilege scopes: access tokens for destructive operations should have a lifetime measured in minutes, not hours. A fifteen-minute window forces re-authentication for anything risky.
Here is what enforcement looks like across the three stacks:
# Python (FastAPI): per-route scope enforcement
from fastapi import Depends, HTTPException
def require_scope(required_scope: str):
def dependency(token_data: TokenData = Depends(decode_token)):
if required_scope not in token_data.scopes:
raise HTTPException(status_code=403, detail="Insufficient scope")
return token_data
return dependency
@app.delete("/users/{user_id}")
async def delete_user(user_id: int, _=Depends(require_scope("write:users:delete"))):
...// Laravel (Sanctum): token abilities per route
// When issuing the token:
$token = $user->createToken('mobile-app', ['read:profile', 'read:orders']);
// Protect routes by ability:
Route::middleware(['auth:sanctum', 'abilities:read:profile'])->group(function () {
Route::get('/profile', [ProfileController::class, 'show']);
});
// Or inline in a controller:
if (!$request->user()->tokenCan('write:orders')) {
abort(403, 'Insufficient token scope');
}// Go: middleware-based scope check
func requireScope(scope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(Claims)
if !ok || !slices.Contains(claims.Scopes, scope) {
http.Error(w, "insufficient scope", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// Apply to routes:
mux.Handle("DELETE /users/{id}", requireScope("write:users:delete")(deleteUserHandler))The implementation is not complicated. The decision to implement it rarely gets made until after the incident.
These five issues appear in production systems because they are invisible until something goes wrong. The auth flow works. The token gets issued. The test suite is green. What does not get tested is what happens when the token is stolen, when the redirect lands somewhere unexpected, when the refresh token is replayed from a different device.
Read RFC 9700. It is eighteen pages. It documents the attacks people took years to fix in practice. The implementations that follow it are not harder to build. They just require knowing the spec exists.


