If you shipped a CLI tool or a single-page app in the last few years, you have probably implemented “OAuth” by copying an authorization URL, slapping in a state parameter, and hoping for the best. That usually works, and it is also how refresh tokens end up in plaintext log files, how authorization codes get intercepted by malicious apps, and how a 2016-era attack called mix-up still lands in 2026 code reviews.
The IETF has spent a decade turning the school of hard knocks into normative text. The two documents that matter for anyone building a client today are RFC 9700, the OAuth 2.0 Security Best Current Practice, and the OAuth 2.1 draft (draft-ietf-oauth-v2-1-16 as of September 2026), which consolidates RFC 6749 with everything the security BCP mandates. This post translates those documents into the specific, concrete differences between an OAuth implementation that passes review and one that quietly accumulates CVEs.
What OAuth 2.1 actually deletes and mandates
OAuth 2.1 is not a new protocol. It is RFC 6749 with the history removed: everything that turned out to be a mistake is gone, and everything the industry converged on is now the default. Three changes carry most of the weight:
- The implicit grant is gone. The flow that returned access tokens directly in the URL fragment — designed for SPAs that could not store secrets — cannot be protected by PKCE and leaks tokens through browser history, referer headers, and service workers. It is formally deprecated in RFC 9700 and absent from OAuth 2.1.
- The resource owner password grant is gone. Credentials passing through your client code defeats the entire point of delegation, breaks every federated login flow, and cannot support MFA. Both BCP and 2.1 remove it.
- PKCE is mandatory for every client type. Not just mobile apps — every authorization code flow, including confidential server-side clients, uses Proof Key for Code Exchange. RFC 9700 states public clients MUST use it and recommends it for confidential clients; OAuth 2.1 makes it unconditional.
The practical consequence: if your authorization request does not include code_challenge and code_challenge_method=S256, your implementation is not OAuth 2.1, and per RFC 9700 it is not even best-practice OAuth 2.0.
PKCE for server-side apps: why a confidential client needs it
The most common pushback is “my client secret already protects the token endpoint.” That argument misses what PKCE actually defends: the authorization code, which travels through the user’s browser before your secret is ever involved. If a code is intercepted — via a referrer leak, a misconfigured redirect, or log scrubbing gone wrong — the attacker cannot redeem it without the verifier. The secret only guards one leg of the journey.
The mechanics are simple enough to implement from scratch, which is worth doing once to understand what your OAuth library does:
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"net/url"
)
// randomURIString returns 32 random bytes, base64url-encoded without padding.
func randomURIString() string {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
panic(err) // crypto/rand failure is not recoverable
}
return base64.RawURLEncoding.EncodeToString(buf)
}
// S256 transforms the verifier into the code challenge sent to the AS.
func s256(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
func buildAuthorizeURL(authEndpoint, clientID, redirectURI string) (authURL, verifier string) {
verifier = randomURIString()
challenge := s256(verifier)
q := url.Values{}
q.Set("response_type", "code")
q.Set("client_id", clientID)
q.Set("redirect_uri", redirectURI)
q.Set("scope", "read:events")
q.Set("state", randomURIString())
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
return authEndpoint + "?" + q.Encode(), verifier
}
Two details people get wrong here. First, the verifier alphabet matters: PKCE’s grammar allows unreserved URI characters only, and base64url without padding satisfies it — standard base64’s + and / do not. Second, the challenge method must be S256. The plain method, where the challenge equals the verifier, was deprecated for a reason: anything that can read the authorization URL learns the challenge, and under plain that is also the secret. Libraries defaulting to plain when the method parameter is omitted still exist in the wild; reject them at review.
The snippets in this section are shown as fragments of one package: each block lists the imports it needs, and in a real project they would share a single import block.
The token exchange then closes the loop:
import (
"fmt"
"net/http"
"net/url"
)
func exchangeCode(tokenEndpoint, code, verifier, clientID, redirectURI string) error {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("code_verifier", verifier)
form.Set("client_id", clientID)
form.Set("redirect_uri", redirectURI)
resp, err := http.PostForm(tokenEndpoint, form)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("token exchange failed: %s", resp.Status)
}
// parse access_token / refresh_token from the JSON body
return nil
}
Redirect URIs for CLIs: the loopback trick
CLIs have no web server and no registered domain, which historically pushed developers toward two bad options: printing tokens in the terminal, or embedding a “secret” in the binary. RFC 8252 (OAuth 2.0 for Native Apps) defines the correct pattern for desktop tools: the CLI starts a temporary HTTP listener on the loopback interface, uses it as the redirect target, and shuts it down as soon as the authorization response arrives.
The details that make this secure are worth internalizing:
- Bind to the loopback IP literal, not
localhost. The RFC recommendshttp://127.0.0.1:{port}/...over thelocalhosthostname, becauselocalhostcan resolve to external interfaces on misconfigured hosts and DNS resolution adds a spoofing surface that a literal IP eliminates. - Let the OS assign the port, and expect the AS to allow any port. RFC 8252 requires authorization servers to accept loopback redirect URIs with arbitrary ports, because the CLI cannot know which ephemeral port will be free. If your authorization server demands a fixed loopback port, that is a server-side spec violation worth filing.
- Open the listener late, close it early. The port should exist only for the duration of the round trip. A CLI that leaves its redirect listener running is an open door on the developer’s laptop.
- PKCE is not optional here. On many operating systems, any local process can connect to a loopback port. The threat model explicitly assumes code interception by other apps on the same machine, and PKCE is the countermeasure.
A minimal listener, skipping TLS because loopback traffic never leaves the device:
import (
"fmt"
"html"
"net"
"net/http"
"time"
)
func loopbackRedirect(path string) (redirectURI string, codeCh <-chan string, shutdown func(), err error) {
ln, err := net.Listen("tcp", "127.0.0.1:0") // OS-assigned port
if err != nil {
return "", nil, nil, err
}
port := ln.Addr().(*net.TCPAddr).Port
redirectURI = fmt.Sprintf("http://127.0.0.1:%d%s", port, path)
ch := make(chan string, 1)
mux := http.NewServeMux()
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if e := q.Get("error"); e != "" {
fmt.Fprintf(w, "<h1>Authorization failed</h1><p>%s</p>", html.EscapeString(e))
ch <- ""
return
}
fmt.Fprint(w, "<h1>Authorized. You can close this tab.</h1>")
ch <- q.Get("code")
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go srv.Serve(ln)
shutdown = func() { srv.Close() }
return redirectURI, ch, shutdown, nil
}
Note the missing pieces that matter in production: the handler must validate the state parameter against the value generated earlier before trusting the code, and the main flow needs a timeout so a user who abandons the browser tab does not leave a listener bound forever.
SPAs: no more excuses for tokens in the browser
For years the guidance for single-page apps oscillated between “implicit is fine” and “implicit is deprecated, use code flow with PKCE” — leaving SPAs holding access tokens in JavaScript memory either way. The OAuth 2.0 for Browser-Based Apps draft settles the architecture question: SPAs should behave like confidential clients that happen to render in a browser. The token endpoint lives behind a backend-for-frontend (BFF): the SPA authenticates to its own backend with a session cookie, and the backend holds the OAuth tokens server-side. JavaScript never sees an access token, which removes XSS-based token theft from the threat model almost entirely.
When a BFF is genuinely impossible and the SPA must call APIs directly, code flow with PKCE is the fallback, and the hardening rules get stricter: refresh tokens must be either sender-constrained (DPoP) or rotated on every use with reuse detection, access token lifetimes should be short, and the team accepts that any XSS is a full account takeover. That acceptance is the part reviewers should challenge.
The rest of the RFC 9700 checklist
Beyond PKCE and grants, the BCP packs a set of smaller requirements that each close a documented attack:
- Exact redirect URI matching. No prefix matching, no wildcards, no query parameters in registered URIs. Partial-match schemes enable open-redirector attacks that siphon codes. The one exception is the loopback port, per RFC 8252.
stateis mandatory and must be unguessable. It binds the response to the request, and PKCE complements rather than replaces it —stateis for CSRF in the browser, PKCE is for code interception at the token endpoint. Implement both.- Mix-up defense. When a client supports multiple authorization servers, an attacker can trick the client into sending a code from AS-A to the token endpoint of AS-B. The countermeasure is binding the AS identity into the request — a distinct
redirect_uriper AS or the AS’s issuer in the authorization request — and validating it on the way back. - Refresh tokens must be restricted or rotating. For public clients, refresh tokens should be sender-constrained or one-time-use: a refresh token that fails on the second presentation (because it was already used by an attacker) triggers token-family revocation, converting a theft into a detectable event.
- Sender-constrained tokens where feasible. DPoP or mutual TLS binds tokens to a key pair, so a stolen access token is useless without the private key. The BCP recommends this wherever the deployment can support it.
What to do on Monday
If you maintain a client, the audit order that catches the most issues fastest: grep your authorization requests for code_challenge_method (absent means non-compliant); check every response_type=token or password-grant call and plan its removal; verify redirect URI comparison is string-equality, not strings.HasPrefix; and confirm refresh token rotation or sender-constraining on every public client. Server operators have the complementary checklist: reject authorization requests without PKCE from public clients, enforce exact redirect matching with the loopback-port exception, and publish issuer identifiers so mix-up defense is possible.
OAuth 2.1 will land as an RFC eventually, but nothing in it should be news to your codebase. The draft’s contents are simply the accumulated post-mortems of fifteen years of real-world attacks — and every item on it is something you can adopt today without waiting for the final document.