Implicit and Password Grants: Why They Are Gone
Two flows you will still find in tutorials, what they were for, and what replaced them.
OAuth 2.0 defined four grant types. Two of them are now actively discouraged, and OAuth 2.1 removes them. You will still meet both in old code and old blog posts.
Implicit grant (response_type=token)
Designed for browsers before CORS was universal: the authorization server put the access token directly in the redirect URL fragment, so an SPA could read it without a back-channel call.
Why it is dead:
- The token lands in the URL — browser history, referrers, logging proxies, shoulder-surfing.
- No client authentication at all, and no PKCE, so an intercepted redirect hands over a working token.
- No refresh token, so implementations resorted to hidden iframes to renew — which third-party cookie blocking has since broken anyway.
Replacement: Authorization Code + PKCE. CORS made the back-channel exchange possible from a browser, which removed implicit's only justification.
Resource owner password credentials (ROPC)
The client collects the user's username and password and posts them to /token. It exists only as a migration aid for legacy apps.
Why it is dead:
- It defeats the entire purpose of OAuth — the app handles the password again.
- It cannot support MFA, CAPTCHA, risk checks, or any interactive step.
- It cannot be used with an external identity provider.
- It trains users to type their credentials into third-party UIs, which is the phishing pattern.
Replacement: Authorization Code + PKCE, with a system browser on mobile and a redirect in the web.
What remains
| Grant | Status | Use for |
|---|---|---|
| Authorization Code + PKCE | ✅ recommended | every user-facing client |
| Client Credentials | ✅ recommended | machine-to-machine |
| Device Code | ✅ recommended | input-constrained devices |
| Refresh Token | ✅ recommended | renewing access |
| Implicit | ❌ removed in 2.1 | — |
| Password (ROPC) | ❌ removed in 2.1 | — |
Your own login is not ROPC
One clarification: your first-party app posting an email and password to your own /login endpoint is not the ROPC grant and is perfectly fine. ROPC specifically means using OAuth's password grant, usually against a third-party authorization server.
Example
# ❌ Implicit — the token is in the URL, and stays in history
https://auth.dfg.com/authorize?response_type=token&client_id=abc-spa
&redirect_uri=https://abc.com/callback
# comes back as:
https://abc.com/callback#access_token=ya29.a0Af...&expires_in=3600
# ^ in the address bar, in history, in any Referer
# ❌ ROPC — the app sees the password, and MFA becomes impossible
curl -X POST https://auth.dfg.com/oauth/token \
-d grant_type=password -d username=alice -d password=s3cret \
-d client_id=abc-spa
# ✅ Authorization Code + PKCE — replaces both
https://auth.dfg.com/authorize?response_type=code&client_id=abc-spa
&redirect_uri=https://abc.com/callback
&code_challenge=E9Melhoa2Owv...&code_challenge_method=S256&state=...When to use it
- A legacy SPA using the implicit grant is migrated to Authorization Code + PKCE, moving tokens out of the URL and enabling refresh tokens.
- A mobile app abandons ROPC when the company introduces mandatory MFA, since the password grant has no way to prompt for a second factor.
- A security review flags access tokens in server access logs, traced back to an implicit-grant redirect that put them in the query string.
More examples
Migrating implicit to code + PKCE
Three improvements in one migration: the token leaves the URL, PKCE binds the exchange to this client, and offline_access finally makes refresh tokens available.
// ---------- BEFORE: implicit ----------
location.assign('https://auth.dfg.com/authorize?' + new URLSearchParams({
response_type: 'token', // ← token straight into the URL
client_id: 'abc-spa',
redirect_uri: 'https://abc.com/callback',
scope: 'openid profile',
}));
// on the callback page
const token = new URLSearchParams(location.hash.slice(1)).get('access_token');
localStorage.setItem('token', token); // ← and now it is XSS-readable too
// ---------- AFTER: code + PKCE ----------
const verifier = randomVerifier();
sessionStorage.setItem('pkce_verifier', verifier);
location.assign('https://auth.dfg.com/authorize?' + new URLSearchParams({
response_type: 'code', // ← a code, not a token
client_id: 'abc-spa',
redirect_uri: 'https://abc.com/callback',
scope: 'openid profile offline_access', // ← and now refresh is possible
state: randomVerifier(),
code_challenge: await challengeFor(verifier),
code_challenge_method: 'S256',
}));
// on the callback page: exchange over the back channel, keep the token in memory
const tokens = await exchangeCode(params.get('code'), verifier);
setAccessTokenInMemory(tokens.access_token);
history.replaceState({}, '', '/'); // ← nothing sensitive left in the URLTelling ROPC apart from your own login
This confusion causes real arguments in code review. Owning the user table means owning the password check; ROPC is about handing credentials to someone who does not.
// ❌ ROPC: the OAuth password grant, usually against a third party.
// Your app handles someone else's credentials. Avoid.
await fetch('https://auth.thirdparty.com/oauth/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'password',
username, password,
client_id: 'abc-spa',
}),
});
// ✅ Your own first-party login endpoint. Not ROPC, and entirely fine —
// you are the identity provider here, so you already hold the password hash.
await fetch('https://dfg.com/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
// The distinction: does a party OTHER than the credential's owner-of-record
// get to see the password? If yes, that is what OAuth exists to prevent.
Discussion