ROPC Is Dead: How to Get User Tokens Without It
RFC 9700 prohibits OAuth2 ROPC. Practical migration guide for headless CLIs and APIs: Device Flow, Auth Code + PKCE, Token Exchange, and PATs with code examples.

ROPC Is Dead: How to Get User Tokens Without It
A practical migration guide for headless CLIs and APIs that need user-context tokens now that OAuth2 ROPC is prohibited.
Who this is for: Developers who use grant_type=password in CLIs, APIs, or scripts and need to migrate to a supported OAuth flow.
Reading time: ~18 minutes | Companion repository: ropc-alternative-flows-poc (Spring Boot 3.4 + Keycloak PoC)
TL;DR: RFC 9700 (January 2025) says ROPC "MUST NOT be used" — the strongest prohibition the IETF has. OAuth 2.1 removes it entirely. If your CLI or headless API collects usernames and passwords to get tokens via grant_type=password, you need to migrate. The Device Authorization Grant (RFC 8628) is the primary replacement for headless scenarios. Auth Code + PKCE with a localhost redirect works when a browser is available. This guide walks through both with complete HTTP examples, a decision tree, and a migration checklist.
1. What Happened to ROPC?
RFC 9700, titled "Best Current Practice for OAuth 2.0 Security", was published in January 2025. Section 2.4 is direct:
"The resource owner password credentials grant MUST NOT be used."
That's IETF "MUST NOT" — not a suggestion, not a deprecation warning. It's the strongest prohibition in RFC vocabulary. The reasoning: ROPC exposes user credentials directly to the client application, bypasses MFA, enables credential stuffing, and eliminates any chance of the authorization server enforcing its own security policies.
OAuth 2.1 (draft-ietf-oauth-v2-1-15, current as of March 2026) goes even further. ROPC isn't deprecated; it's removed entirely. Section 1.3 lists three grant types: authorization code, refresh token, and client credentials. ROPC simply doesn't exist anymore.
This article won't repeat the security arguments. Scott Brady's breakdown lists 9 specific problems, and WorkOS's RFC 9700 summary covers the standards context well. Instead, this guide focuses on what you should use instead.
2. The Real Problem: User Tokens Without a Browser
If you're reading this, you probably chose ROPC for a reason. You had a CLI, a headless API, or a batch script that needed to act on behalf of a specific user. grant_type=password was one HTTP request:
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=password&username=alice&password=s3cret&client_id=my-cli&scope=openid
One POST. One response. You have a token. No browser, no redirects, no local server. It was genuinely simple, and that simplicity is exactly why it was dangerous. The client application never needed to see Alice's password, but ROPC forced it to act as an intermediary that handles her credentials directly.
Now every recommended alternative seems to require a browser. If you're running a CLI over SSH into a headless server, "just add a redirect" isn't helpful.
The good news: the industry already solved this problem. GitHub CLI, Azure CLI, and AWS CLI all authenticate users from headless environments without ROPC. The patterns exist. You just need to know which one fits your scenario.
3. Decision Tree: Which Flow Replaces ROPC?
Not every ROPC migration looks the same. Your replacement flow depends on two questions: does the user's machine have a browser? and does the token need to represent a specific user?
Does the token need to represent a specific user?
├── No → Client Credentials (grant_type=client_credentials)
│ You never needed ROPC. Service accounts work fine.
│
└── Yes → Is a browser available on the device running the CLI?
├── Yes → Authorization Code + PKCE with localhost redirect
│ CLI opens browser, catches the callback on localhost.
│ (Section 5)
│
└── No → What kind of usage?
├── Interactive (user present) → Device Authorization Grant
│ CLI shows a URL + code, user authenticates elsewhere.
│ (Section 4)
│
└── Non-interactive (CI/CD, scripts) → Personal Access Tokens
User generates a PAT via web UI, configures it in CLI.
(Section 7)
One more scenario: if your service already has a user token and needs to call a downstream API preserving user identity, that's Token Exchange (RFC 8693). See Section 6.
| Scenario | Flow | Section |
| Headless CLI, user present | Device Authorization Grant | 4 |
| CLI with browser available | Auth Code + PKCE (localhost) | 5 |
| Service-to-service, user context | Token Exchange / On-Behalf-Of | 6 |
| Scripts, CI/CD, automation | Personal Access Tokens | 7 |
| No user context needed | Client Credentials | N/A |
4. The Device Authorization Grant — Your Primary ROPC Replacement
The Device Authorization Grant (RFC 8628) was designed precisely for devices that can't open a browser — smart TVs, IoT sensors, and yes, headless CLIs. It's what GitHub CLI uses when you run gh auth login.
Here is how it works at a high level: your CLI requests a short code from the authorization server and displays it to the user. The user then opens a browser on any other device (their phone, their laptop) and enters that code to authenticate. Meanwhile, the CLI keeps checking the token endpoint until the user finishes.
The Full Flow: Step by Step
Step 1: Request device and user codes
Your CLI sends a POST to the authorization server's device authorization endpoint:
POST /realms/my-realm/protocol/openid-connect/auth/device HTTP/1.1
Host: keycloak.example.com
Content-Type: application/x-www-form-urlencoded
client_id=my-cli-app&scope=openid profile offline_access
Step 2: Server responds with codes
{
"device_code": "df1b060b-4e36-4bbe-98aa-5dcb11909f5f",
"user_code": "DRTD-NTJC",
"verification_uri": "https://keycloak.example.com/realms/my-realm/device",
"verification_uri_complete": "https://keycloak.example.com/realms/my-realm/device?user_code=DRTD-NTJC",
"expires_in": 600,
"interval": 5
}
Key fields:
device_code: Backend identifier for polling — never shown to the useruser_code: Short, human-readable code likeDRTD-NTJC— this is what the user typesverification_uri: Where the user goes to authenticateinterval: Minimum seconds between poll requests (respect this or get rate-limited)expires_in: The codes expire after this many seconds (typically 600)
Step 3: Display instructions to the user
Your CLI prints something like:
To sign in, open https://keycloak.example.com/realms/my-realm/device
and enter code: DRTD-NTJC
Waiting for authentication...
Some CLIs copy the code to the clipboard automatically (GitHub CLI does this). If verification_uri_complete is available, you can also display a QR code.
Step 4: User authenticates on another device
The user opens the URL on any device with a browser — their phone, a laptop, whatever. They enter the user code, log in with their credentials (including MFA if configured), and approve the authorization. This happens entirely on the authorization server's own login page. Your CLI never sees the password.
Step 5: CLI polls the token endpoint
While the user is authenticating, your CLI polls:
POST /realms/my-realm/protocol/openid-connect/token HTTP/1.1
Host: keycloak.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=df1b060b-4e36-4bbe-98aa-5dcb11909f5f
&client_id=my-cli-app
Before the user completes login, you get:
HTTP/1.1 400 Bad Request
{ "error": "authorization_pending" }
This is expected — keep polling at the interval rate.
Step 6: Receive tokens
After the user authorizes:
HTTP/1.1 200 OK
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile offline_access"
}
You now have a user-context token — representing the specific user who authenticated — without ever touching their password.
Handling Polling Errors
Your polling loop needs to handle four error codes:
| Error | Meaning | What to Do |
authorization_pending | User hasn't finished authenticating | Keep polling at the same interval |
slow_down | You're polling too frequently | Add 5 seconds to your interval |
expired_token | The codes expired (user took too long) | Restart the entire flow from Step 1 |
access_denied | User denied the authorization | Stop polling, show an error message |
On connection timeouts, use exponential backoff. Don't send rapid repeated requests to the endpoint.
ROPC vs. Device Flow — Side by Side
| Property | ROPC | Device Flow |
| Credentials exposed to client | Yes — client sees the password | No — user authenticates on IdP's page |
| MFA support | No | Yes — IdP handles MFA natively |
| SSO support | No | Yes — same IdP session across apps |
| Phishing resistance | None | Higher — codes entered on trusted domain |
| Browser on same device | Not required | Not required |
| User context in token | Yes | Yes |
| Complexity | 1 HTTP request | Polling loop + user interaction |
You trade simplicity for security. One HTTP request becomes a polling loop. But your CLI never sees a password, MFA works without any extra configuration, and the authorization server stays in control of the login experience.
Security Note: Device Code Phishing (Storm-2372)
The Device Authorization Grant is not immune to phishing attacks. In February 2025, Microsoft reported that a Russia-linked group called Storm-2372 used device code flows to steal tokens. The attack works like this:
- Attacker generates a legitimate device code
- Sends the user_code to victims via WhatsApp, Signal, or Teams
- Victim enters the code on the real IdP login page
- Attacker's device receives the token via polling
Mitigations:
- Restrict device code flow to applications that truly need it (Conditional Access policies in Entra ID, client-level toggles in Keycloak)
- Educate users: device codes should only come from actions you initiated
- Deploy anomaly detection for unusual device code patterns
- Monitor token usage after device code grants
This does not make Device Flow worse than ROPC. ROPC hands the password directly to the client, which is a bigger risk. But you should be aware of this attack vector when adopting Device Flow.
Companion PoC: Spring Boot 3.4 + Keycloak
Hands-on reference: The ropc-alternative-flows-poc repository demonstrates a complete Device Authorization Flow implementation using Spring Boot 3.4 and Keycloak 26. It includes a headless CLI client that polls for tokens, a protected resource server, Keycloak realm configuration, and Docker Compose for local development. Clone it and run the full flow locally in under 10 minutes.
5. Auth Code + PKCE with Localhost Redirect
When a browser is available on the same machine (for example, a developer workstation rather than a headless server), Authorization Code + PKCE with a localhost redirect provides a better user experience. The CLI opens the system browser, the user logs in, and the browser redirects back to a temporary local server that receives the authorization code.
This is what Azure CLI (az login) and AWS CLI (aws sso login) use by default.
How It Works
- CLI generates a PKCE
code_verifier(random 43-128 character string) and derivescode_challenge(SHA256, base64url-encoded) - CLI starts a temporary HTTP server on
http://localhost:<port> - CLI opens the system browser to the authorization URL
- User authenticates on the IdP's login page
- IdP redirects to
http://localhost:<port>?code=<auth_code>&state=<state> - Local server catches the redirect, extracts the authorization code
- CLI exchanges authorization code +
code_verifierfor tokens - Local server shuts down
Code Example: Node.js with openid-client 5.x
import { Issuer, generators } from 'openid-client'; // v5.7.0
import http from 'node:http';
import open from 'open'; // v10.1.0
const REDIRECT_PORT = 6363;
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}`;
// 1. Discover IdP endpoints via OIDC discovery
const issuer = await Issuer.discover('https://keycloak.example.com/realms/my-realm');
const client = new issuer.Client({
client_id: 'my-cli-app',
redirect_uris: [REDIRECT_URI],
response_types: ['code'],
token_endpoint_auth_method: 'none', // public client — no client secret
});
// 2. Generate PKCE values
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
// 3. Build authorization URL
const authUrl = client.authorizationUrl({
scope: 'openid profile offline_access',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
// 4. Start localhost server, open browser, exchange code for tokens
const tokenSet = await new Promise((resolve, reject) => {
const server = http.createServer(async (req, res) => {
try {
const params = client.callbackParams(req);
const tokens = await client.oauthCallback(REDIRECT_URI, params, {
code_verifier: codeVerifier,
});
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>Authenticated. You can close this tab.</h1>');
server.close();
resolve(tokens);
} catch (err) {
res.writeHead(500);
res.end('Authentication failed.');
server.close();
reject(err);
}
});
server.listen(REDIRECT_PORT, () => {
console.log(`Listening on ${REDIRECT_URI}`);
open(authUrl); // opens system browser
});
});
console.log('Access token:', tokenSet.access_token);
Port Conflict Handling
Your localhost server might fail to bind if the port is already in use. Two strategies:
- Fixed port with retry: Try a predefined list of ports (e.g., 6363, 6364, 6365). Register all of them as valid redirect URIs in your IdP.
- Dynamic port: Bind to port 0 (OS assigns a free port). This requires your IdP to support wildcard or dynamic redirect URIs — most don't.
Terraform's docs recommend configuring ports 10000-10010 as the redirect port range. It works, even if the approach is simple.
When to Choose This Over Device Flow
| Factor | Device Flow | Localhost PKCE |
| Browser on same machine | Not required | Required |
| UX | Copy code → switch to browser | Browser opens automatically |
| Headless server / SSH | Works | Doesn't work |
| Port conflicts | None | Possible |
| Phishing risk | Device code phishing (Storm-2372) | Localhost redirect harder to intercept |
The industry pattern: default to localhost PKCE when a browser is detected, fall back to Device Flow when it isn't. Azure CLI and AWS CLI both do this.
6. What the Big CLIs Actually Do
These aren't theoretical alternatives. Here is how major CLI tools actually handle authentication today:
| CLI Tool | Default Flow | Fallback | Notes |
GitHub CLI (gh auth login) | Device code flow | --with-token for PATs | Displays code (clipboard copy via --clipboard flag). Uses https://github.com/login/device. |
Azure CLI (az login) | Auth Code + PKCE (opens browser) | --use-device-code | Switched to browser-based as the default in recent versions. Windows defaults to WAM since v2.61.0. |
AWS CLI (aws sso login) | Auth Code + PKCE (since v2.22.0) | --use-device-code | Switched from device code to PKCE as default. |
Terraform (terraform login) | Auth Code + PKCE (localhost) | N/A | Uses localhost with configurable port range. No refresh tokens. |
| kubelogin (kubectl OIDC) | Auth Code + PKCE (opens browser) | N/A | Caches ID + refresh tokens locally. |
The trend is clear: Auth Code + PKCE by default, Device Flow as fallback, PATs for automation. This dual-mode approach is the new industry standard.
7. Token Exchange and Personal Access Tokens
Two more alternatives complete the overview. Neither is a direct ROPC replacement for end-user login, but both solve scenarios where developers previously used ROPC.
Token Exchange / On-Behalf-Of (RFC 8693)
This solves a different problem: your API already received a user token (from Device Flow, Auth Code, etc.) and needs to call a downstream API while preserving the user's identity.
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=eyJhbGciOiJSUzI1NiIs...
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=https://downstream-api.example.com
&scope=read write
The authorization server issues a new token that represents the same user but is scoped for the downstream API. Supported by Entra ID (as "On-Behalf-Of"), Okta, Keycloak, and Auth0.
Use Token Exchange when: a microservice needs to call another service on behalf of the user who initiated the request. Don't use it as a standalone login mechanism — it requires an existing user token as input.
Personal Access Tokens (PATs)
When a full OAuth flow is more than you need (CI/CD pipelines, simple scripts, automation), PATs are the practical choice. The user generates a token through a web UI and pastes it into their CLI config or an environment variable.
GitHub, GitLab, npm, and Docker Hub all use this pattern. GitHub's fine-grained PATs even let you scope permissions per-repository.
| Factor | PATs | OAuth Flow (Device/PKCE) |
| Setup complexity | Low — generate in web UI | Higher — implement flow in client |
| Token lifetime | Long-lived | Short-lived access + refresh |
| Revocation | Manual via web UI | Automatic expiration |
| MFA enforcement | At creation time only | At each authentication |
| Best for | Scripts, CI/CD, simple tools | Production CLIs, user-facing apps |
Use PATs when: the "user" is a build pipeline or a one-off script that doesn't need interactive login. Don't use them as a general-purpose ROPC replacement in production CLIs — they lack automatic expiration and per-session MFA.
8. Identity Provider Support Matrix
Before you choose a flow, verify that your identity provider (IdP) supports it. Device Flow has broad support, with one notable exception.
| IdP | Device Flow | Auth Code + PKCE | Token Exchange | Notes |
| Keycloak 26.2+ | Native | Native | Native (GA since 26.2; preview in 26.0) | Enable "OAuth 2.0 Device Authorization Grant" per client. Configure code lifespan and polling interval. |
| Microsoft Entra ID | Native | Native | Native (OBO) | Restrict via Conditional Access. Use --use-device-code in Azure CLI. |
| Auth0 | Native | Native | Native (Token Vault) | Enable Device Authorization grant on the application settings page. |
| Okta | Native | Native | Native | Enable grant type on the app + authorization server policy rule. |
| AWS Cognito | Not native | Native | Not native | Device Flow requires a Lambda + DynamoDB workaround. |
If you use Cognito and need Device Flow, the AWS-provided workaround uses Lambda to implement the flow on top of Cognito. It works, but it requires significantly more infrastructure than native support. You should evaluate whether switching to a different IdP would reduce complexity enough to justify the effort.
9. Migration Checklist
Follow these steps to move from ROPC to a modern flow without breaking existing users.
Step 1: Audit
- [ ] Search your codebase for
grant_type=password - [ ] Identify every client that uses ROPC — CLI tools, SDKs, internal scripts, CI/CD pipelines
- [ ] Document which of those need user-context tokens vs. service accounts (client credentials)
- [ ] Check your IdP's ROPC deprecation timeline (some will force-disable it)
Step 2: Choose Your Flow
Use the decision tree from Section 3:
- Headless + user present → Device Authorization Grant
- Browser available → Auth Code + PKCE (localhost redirect)
- Non-interactive automation → PATs or Client Credentials
- Service-to-service user propagation → Token Exchange
Step 3: Implement
- [ ] Register a new OAuth client in your IdP with the appropriate grant type enabled
- [ ] For Device Flow: implement the polling loop with proper error handling (
authorization_pending,slow_down,expired_token,access_denied) - [ ] For Auth Code + PKCE: implement the localhost redirect server with port fallback
- [ ] Request
offline_accessscope to get refresh tokens — your CLI shouldn't re-authenticate on every invocation - [ ] Store tokens securely (OS keychain, encrypted file, not plaintext in
~/.config)
Step 4: Parallel Run
- [ ] Ship the new flow alongside ROPC (e.g.,
--use-device-codeflag) - [ ] Log ROPC usage to track migration progress
- [ ] Communicate the deprecation timeline to your users. Give them at least one release cycle to switch
Step 5: Deprecate ROPC
- [ ] Remove
grant_type=passwordfrom client code - [ ] Disable ROPC on the IdP (Keycloak: uncheck "Direct Access Grants Enabled"; Entra ID: block via Conditional Access)
- [ ] Verify no remaining clients are using ROPC via IdP logs
What's Next
Pick one client that uses grant_type=password and migrate it to Device Flow this week. Start with the ropc-alternative-flows-poc PoC if you want a working reference — it has a Spring Boot 3.4 resource server, a Keycloak 26 realm, and a CLI client that demonstrates the full polling flow.
If your CLI runs on machines with browsers, implement the dual-mode pattern: Auth Code + PKCE by default, Device Flow via a --use-device-code flag. That's what Azure CLI and AWS CLI converged on, and it covers every deployment scenario.
ROPC was simple. Its replacements require more steps, but they are fundamentally safer. Your CLI stops handling user passwords and becomes what it should have been from the start: a token consumer that never touches credentials.
If this article saved you time, consider buying me a coffee:


