RFC 8252 is one of those documents that looks slim until you start implementing it. At sixteen pages it should take maybe thirty minutes to read. But the gaps between the lines are where the bugs live — and they're the kind of bugs that end up in security incident reports.
This isn't a tutorial. If you're building a mobile or desktop app that needs OAuth, you've probably already read something about PKCE and redirect URIs. What follows are seven specific things the RFC says that teams consistently either miss, misread, or consciously ignore because they assume they can get away with it.
1. WebViews are not just discouraged — they're forbidden
This is the one that still surprises people. It's not that embedded web views are slightly frowned upon. Section 8.12 is unambiguous: native apps MUST NOT use embedded user-agents for authorization requests. The RFC also explicitly permits authorization servers to detect and block them.
The reason isn't pedantic. A WKWebView or an Android WebView running inside your app shares a security boundary with your app. That means:
- Your code can intercept every keystroke on the login form
- You can read session cookies
- You can auto-submit forms, bypassing user consent dialogs
Even when the app is from the same vendor as the authorization server, this violates the principle of least privilege. The app gets access to the full credential — username and password — when it only ever needed the authorization code.
The RFC puts it plainly: "the host application can record every keystroke entered in the login form to capture usernames and passwords." That's not a theoretical attack, it's describing what WebViews natively support.
The fix is to use the browser — either the full system browser or an in-app browser tab (SFSafariViewController / ASWebAuthenticationSession on iOS, Custom Tabs on Android). These run in a separate security context. Your app cannot reach into them.
There's also a subtler benefit: when everyone uses external user-agents, a fake one is provable evidence of a bad actor. When WebViews are common, malicious apps blend right in.
2. PKCE is mandatory, not optional
If you've only skimmed the spec, you might have missed that public native app clients MUST implement PKCE (RFC 7636), and authorization servers MUST support it for those clients. This isn't a "SHOULD" — it's a "MUST" on both sides.
The mechanism is simple. Before starting the flow, the client generates a random code_verifier, hashes it (S256), and sends the hash as code_challenge in the authorization request. When exchanging the authorization code for a token, it includes the raw code_verifier. The server verifies the hash matches.
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
}
Why does this matter for native apps specifically? Because redirect URIs on mobile are not exclusive. If an attacker installs an app that registers the same custom URI scheme as yours, the OS may deliver the authorization code to their app instead. With PKCE, that intercepted code is worthless — they don't have the verifier, so the token exchange will fail.
The RFC recommends that servers reject authorization requests from native app clients that don't include PKCE. If you're running the auth server yourself, this is worth enforcing. It costs you nothing and closes a real attack vector.
3. Three redirect URI patterns, and they are not interchangeable
The RFC defines three valid ways to redirect back to a native app, each with different trade-offs. Most teams pick one and assume it works everywhere. It doesn't.
Private-use URI schemes (com.example.app:/oauth2redirect/...) — broad OS support, works on mobile and desktop. The downside is that any app can register the same scheme. There's no enforcement. The RFC requires schemes to be based on a domain you control, in reverse notation, for this reason. myapp:// is explicitly non-compliant; com.example.myapp:// is compliant. Servers should reject schemes without a period character.
Claimed https scheme URIs (https://app.example.com/oauth2redirect/...) — this is the preferred option where available. The OS verifies domain ownership before routing the redirect to your app (Universal Links on iOS, App Links on Android 6+). An attacker can't claim your domain, so interception is much harder. The catch: not available on all platforms, and requires a hosted .well-known/assetlinks.json or apple-app-site-association file. Authorization servers can't distinguish these from regular web redirect URIs, so client type must be explicitly recorded at registration.
Loopback interface (http://127.0.0.1:{port}/...) — for desktop apps that can open a local port without elevated permissions. The app starts an HTTP listener, sends the port in the redirect URI, and handles the callback there. Useful on Windows, macOS, and Linux where in-app browser tabs don't exist.
The server support checklist in Appendix A says an authorization server supporting native apps must support all three. If you're building or evaluating an auth server, verify this.
4. localhost in loopback redirects is specifically not recommended
This one is subtle and very easy to get wrong. When implementing loopback redirect URIs on desktop, the instinctive thing to do is use http://localhost:{port}/callback. The RFC says not to.
Use http://127.0.0.1:{port}/callback (IPv4) or http://[::1]:{port}/callback (IPv6) instead.
The reasoning from Section 8.3: localhost resolves via the host's DNS/hosts file. On a misconfigured machine, or one where localhost has been overridden, it might not resolve to the loopback address. Worse, it might resolve to an actual network interface, meaning your short-lived HTTP callback server is briefly exposed to the local network rather than isolated to loopback.
There's also a practical firewall issue. Many host-based firewalls treat 127.0.0.1 and localhost differently. Using the IP literal sidesteps that ambiguity entirely.
The RFC also recommends trying to bind to both IPv4 and IPv6 loopback and using whichever is available:
func startCallbackServer() (net.Listener, string, error) {
// try IPv4 first, fall back to IPv6
for _, addr := range []string{"127.0.0.1:0", "[::1]:0"} {
l, err := net.Listen("tcp", addr)
if err == nil {
return l, l.Addr().String(), nil
}
}
return nil, "", fmt.Errorf("could not bind loopback interface")
}
Note the :0 — the OS assigns a free port. The assigned port is then included in the redirect URI. The authorization server must allow any port for loopback redirects.
5. Client secrets in native apps aren't secrets
Section 8.5 is direct about this: a secret embedded in a distributed native app binary should not be treated as confidential. Any user can extract it. The RFC states authorization servers "NOT RECOMMENDED" to require client authentication of native app clients using a shared secret, because it provides essentially no additional security beyond client_id identification.
This matters because teams sometimes ship a client_secret in their mobile app thinking it adds a layer of authentication. It doesn't. Any user who wants it can get it — through proxy interception, string extraction, disassembly, or memory inspection. Once one person has it, it's effectively public.
All native apps should be registered as public clients. If your authorization server prompts you to enter a client secret when registering a mobile app, you should either leave it empty or treat that secret as not confidential.
The only exception the RFC carves out is dynamic client registration (RFC 7591), where each installed instance gets a unique per-instance secret provisioned at install time. This is architecturally different — there's no shared secret across all installs. If you need per-client authentication for native apps, this is the path.
6. The state parameter protects against cross-app request forgery
Section 8.9 doesn't get much attention but describes a real class of attack. The classic web CSRF applies here in a slightly different form: a malicious app can craft an authorization response URI and deliver it to your app via the same custom URI scheme or Universal Link, injecting a code you didn't request.
The mitigation is the state parameter — a high-entropy random value included in the outgoing authorization request and verified on every incoming response. Your app should:
- Generate a cryptographically random
statevalue before opening the browser - Store it alongside the in-progress authorization session
- Reject any incoming authorization response where
statedoesn't match
func startAuthRequest() (stateToken string, authURL string) {
b := make([]byte, 16)
rand.Read(b)
stateToken = base64.RawURLEncoding.EncodeToString(b)
params := url.Values{}
params.Set("response_type", "code")
params.Set("client_id", clientID)
params.Set("redirect_uri", redirectURI)
params.Set("state", stateToken)
// ... add PKCE challenge
return stateToken, authEndpoint + "?" + params.Encode()
}
The state should be stored in memory (not persisted to disk) and cleared as soon as the response arrives or the request times out.
7. If you use multiple authorization servers, each needs a unique redirect URI
Section 8.10 describes authorization server mix-up attacks. The scenario: your app authenticates with two different OAuth servers (say, a corporate IdP and a social login provider). A compromised or malicious server from provider A could redirect the user's browser to provider B's endpoint, collect a code, then redirect back using your app's redirect URI — causing your app to exchange a code it didn't originate.
The mitigation has two parts. First, use a different redirect URI path for each authorization server:
com.example.app:/oauth2redirect/provider-a
com.example.app:/oauth2redirect/provider-b
Second — and this is the part that's easy to forget — your app must store the redirect URI that was used in the outgoing request, and verify that the incoming response arrived on exactly that URI. It's not enough to just check state. The RFC is explicit: "the native app MUST store the redirect URI used in the authorization request with the authorization session data and MUST verify that the URI on which the authorization response was received exactly matches it."
The part the RFC doesn't say, but probably should
One thing missing from the RFC is guidance on what to do with the tokens once you have them. That's out of scope for 8252, but it's worth noting: getting the authorization flow right is only half the problem. Tokens stored in plaintext in shared storage, or cached across app reinstalls without re-validation, are a separate attack surface. Consider platform-specific secure storage (Keychain on iOS/macOS, Android Keystore, OS credential APIs on desktop) and treat refresh tokens especially carefully — they're long-lived and should be bound to device if possible.
RFC 8252 is a Best Current Practice document, not a theoretical spec. Everything in it was written because teams got it wrong in the real world first. The seven points above aren't edge cases — they're the common failure modes. If you're auditing an existing integration rather than starting from scratch, these are the first places to check. An authentication code review covers all seven in a single fixed-fee engagement.
At Reverse Polarity, we help teams get RFC 8252-compliant native app OAuth right through fixed-fee authentication code reviews.
Sources
- RFC 8252 — OAuth 2.0 for Native Apps: rfc-editor.org/rfc/rfc8252
- RFC 6749 — The OAuth 2.0 Authorization Framework: rfc-editor.org/rfc/rfc6749
- RFC 7636 — Proof Key for Code Exchange by OAuth Public Clients: rfc-editor.org/rfc/rfc7636
- AppAuth — Open-source reference implementations for iOS, Android, and macOS: appauth.io
