September 6, 2026
How One OIDC URL Turned Into a Much Bigger SSRF
Finding the fetch was easy. Understanding how far it could go was the interesting part.

By Nozarashi1
8 min read
I wasn't specifically hunting for SSRF when I found this.
I was looking at an authenticated administration panel that allowed administrators to configure an external OIDC identity provider.
The form looked ordinary. It contained the kind of fields you would expect:
authorization_url
token_url
userinfo_url
jwks_url
client_id
client_secretauthorization_url
token_url
userinfo_url
jwks_url
client_id
client_secretWhenever I see functionality like this, one question immediately comes to mind:
Which of these URLs does the backend actually request?
Because there is a big security difference between:
Browser stores URL -> browser later uses URLBrowser stores URL -> browser later uses URLand:
User supplies URL -> application server fetches URLUser supplies URL -> application server fetches URLThe second one is where SSRF can begin.
The first sign
I started with the JWKS URL.
Instead of giving the application a real JWKS endpoint, I used a server I controlled and saved the configuration.
A few seconds later, I received an HTTP request.
That was the first important result.
The request did not come from my browser. It came from the application's infrastructure.
So the flow was effectively:
Administrator
|
| saves OIDC configuration
v
Application backend
|
| GET
v
Attacker-controlled serverAdministrator
|
| saves OIDC configuration
v
Application backend
|
| GET
v
Attacker-controlled serverAt this point I had a server-side request primitive.
But that alone isn't necessarily an interesting SSRF. Plenty of applications intentionally fetch remote configuration.
The important question was whether I could control where the server was allowed to connect.
Testing the obvious internal destinations
My next test was boring on purpose.
I changed the JWKS URL to a loopback/private destination.
The application rejected it with an error similar to:
URL cannot reference private, loopback, or link-local addressesURL cannot reference private, loopback, or link-local addressesThat was actually a good sign for the application's security.
It meant somebody had thought about SSRF.
I also tried alternative representations of IP addresses rather than only a normal dotted address.
The application still detected them.
So this wasn't just:
if "127.0.0.1" in url:
block()if "127.0.0.1" in url:
block()The hostname was apparently being resolved before validation.
For a moment, it looked like this path was properly protected.
Then I started thinking about when the validation happened.
What about redirects?
Imagine the application performs approximately this logic:
validate(user_url)
response = http_client.get(user_url)validate(user_url)
response = http_client.get(user_url)Now imagine the URL is:
https://public.example/redirecthttps://public.example/redirectThe validator sees:
public.example -> public IPpublic.example -> public IPEverything looks fine.
But the server responds:
HTTP/1.1 302 Found
Location: https://internal-address/HTTP/1.1 302 Found
Location: https://internal-address/What happens next depends on the HTTP client.
If redirects are automatically followed, the real request becomes:
Application
|
| request public URL
v
Public redirector
|
| HTTP 302
v
Internal networkApplication
|
| request public URL
v
Public redirector
|
| HTTP 302
v
Internal networkIf the destination is not validated again after the redirect, the original SSRF protection becomes almost useless.
So I tested exactly that.
Direct internal destination:
jwks_url = https://<internal-address>/jwks_url = https://<internal-address>/Result:
Blocked: private destinationBlocked: private destinationThen:
jwks_url = https://<public-redirector>/?url=https://<internal-address>/jwks_url = https://<public-redirector>/?url=https://<internal-address>/This time I received a completely different error:
Couldn't retrieve JWK set: connection timed outCouldn't retrieve JWK set: connection timed outThat difference mattered.
The direct request was rejected before a connection attempt.
The redirected request passed validation and later failed while connecting to the final host.
Same destination.
Different path.
That gave me the differential I wanted:
DIRECT
public validation -> internal IP detected -> BLOCK
REDIRECT
public validation -> public IP accepted
-> HTTP 302
-> internal IP
-> connection attemptedDIRECT
public validation -> internal IP detected -> BLOCK
REDIRECT
public validation -> public IP accepted
-> HTTP 302
-> internal IP
-> connection attemptedThe application was validating the first URL, but trusting the HTTP client after that.
That was the first SSRF bypass.
Then I found something worse
Once I knew the OIDC implementation was making server-side requests, I stopped treating the four URL fields as one feature.
They might look similar in the UI, but backend applications frequently handle them using completely different code paths.
So I tested each field independently.
That turned out to be important.
The JWKS field had:
HTTPS-only validation
private-IP validationHTTPS-only validation
private-IP validationBut another OIDC URL field accepted something equivalent to:
http://<internal-address>/http://<internal-address>/and saved it successfully.
No HTTPS requirement.
No private-address rejection.
No redirect bypass was even necessary.
That immediately changed my model of the vulnerability.
I wasn't looking at:
one badly implemented SSRF filter.
I was looking at:
multiple server-side URL consumers with inconsistent security controls.
Conceptually, the application looked something like this:
OIDC configuration
|
+---------------+---------------+
| | |
JWKS URL Token URL UserInfo URL
| | |
validation weak/no weak/no
+ HTTPS validation validation
|
server fetchOIDC configuration
|
+---------------+---------------+
| | |
JWKS URL Token URL UserInfo URL
| | |
validation weak/no weak/no
+ HTTPS validation validation
|
server fetchThis is one of the reasons I don't stop testing after seeing a good validation error.
Two fields living next to each other in the same JSON object do not mean they use the same backend library or validator.
Saving an SSRF isn't the same as triggering it
There was still an important problem.
I could prove that an internal URL could be stored.
But did the application ever actually request it?
OIDC URLs such as token_url and userinfo_url normally aren't requested while saving configuration.
They're requested later during authentication.
So now I needed to find a way to trigger the OAuth/OIDC flow using only my own test setup.
After mapping the login flows, I found a secondary authentication entry point that used the configuration I controlled.
That was the breakthrough.
I built a small mock OIDC provider on infrastructure I controlled.
Nothing complicated was required conceptually:
/.well-known/openid-configuration
/authorize
/token
/jwks
/userinfo/.well-known/openid-configuration
/authorize
/token
/jwks
/userinfoI generated my own signing key, served the public key through JWKS, and returned properly signed tokens.
Then I initiated the application's login flow.
My server logs started receiving requests.
The sequence looked roughly like this:
Browser
|
| starts login
v
Application
|
+------> attacker /authorize
|
+------> attacker /token
|
+------> attacker /jwks
|
+------> attacker /userinfoBrowser
|
| starts login
v
Application
|
+------> attacker /authorize
|
+------> attacker /token
|
+------> attacker /jwks
|
+------> attacker /userinfoThe interesting part was that the last three requests were performed by the application server.
I now had runtime confirmation that the URLs were not merely stored configuration.
They became network destinations used by the backend.
From blind SSRF to a response channel
Being able to make a server connect to an internal address is useful.
Being able to retrieve the response is much more interesting.
The userinfo request gave me a potential path.
In a normal OIDC flow, the identity provider might return something like:
{
"sub": "12345",
"email": "user@example.test",
"name": "Example User"
}{
"sub": "12345",
"email": "user@example.test",
"name": "Example User"
}The application fetches that JSON and converts it into identity claims.
That means the data flow becomes:
Configured URL
|
v
Server-side HTTP request
|
v
HTTP response body
|
v
OIDC claim parser
|
v
ApplicationConfigured URL
|
v
Server-side HTTP request
|
v
HTTP response body
|
v
OIDC claim parser
|
v
ApplicationThat's a very different primitive from a simple port scan.
The response is actually being consumed by the application.
To prove the data path safely, I made my own /userinfo endpoint return distinctive canaries:
{
"sub": "researcher-test",
"name": "SSRF_RESPONSE_CANARY",
"custom_marker": "CONTROLLED_TEST_VALUE"
}{
"sub": "researcher-test",
"name": "SSRF_RESPONSE_CANARY",
"custom_marker": "CONTROLLED_TEST_VALUE"
}The application processed them as identity claims.
At that point I had demonstrated:
attacker-selected server
->
backend fetch
->
attacker-selected response
->
application data processingattacker-selected server
->
backend fetch
->
attacker-selected response
->
application data processingI deliberately did not try to extract sensitive production information from internal services.
For me, the important part was proving the primitive with controlled data and understanding what would happen if an internal HTTP service returned compatible content.
A completely different feature confirmed the pattern
While mapping other places where administrators could supply network destinations, I found another integration feature.
This one contacted an external API when its configuration was saved.
Again, I pointed it at infrastructure I controlled.
The server connected.
But this sink had an additional behavior: the application forwarded an authentication header whose value was partially controlled by the configuration.
Conceptually:
GET /some/fixed/path HTTP/1.1
Host: attacker.example
Authorization: <attacker-controlled-value>GET /some/fixed/path HTTP/1.1
Host: attacker.example
Authorization: <attacker-controlled-value>More importantly, certain HTTP errors from the remote server were returned inside the application's own API error response.
I tested it with a unique canary body:
SSRF_RESPONSE_BODY_CANARY_93A7SSRF_RESPONSE_BODY_CANARY_93A7My controlled server returned:
HTTP/1.1 404 Not Found
Content-Type: text/plain
SSRF_RESPONSE_BODY_CANARY_93A7HTTP/1.1 404 Not Found
Content-Type: text/plain
SSRF_RESPONSE_BODY_CANARY_93A7The application's API response then contained:
SSRF_RESPONSE_BODY_CANARY_93A7SSRF_RESPONSE_BODY_CANARY_93A7That was a clean response-body reflection primitive.
The chain was now:
Attacker
|
| supplies URL
v
Application backend
|
| GET internal/remote destination
v
Target service
|
| response body
v
Application backend
|
| error message
v
AttackerAttacker
|
| supplies URL
v
Application backend
|
| GET internal/remote destination
v
Target service
|
| response body
v
Application backend
|
| error message
v
AttackerThis was much stronger evidence than simply saying:
"I got a timeout, therefore SSRF."
I had controlled both sides and watched a marker make the complete round trip.
Why different errors matter
One thing that helped a lot during this hunt was treating application errors as network observations.
Suppose I tested three destinations and received:
A -> connection refused
B -> request timed out
C -> HTTP 401A -> connection refused
B -> request timed out
C -> HTTP 401Those aren't just errors.
They can reveal different states:
Connection refused
-> host reachable, port likely closed/rejecting
Timeout
-> filtered, blackholed, or service not responding
HTTP 401
-> TCP connection succeeded
-> HTTP service respondedConnection refused
-> host reachable, port likely closed/rejecting
Timeout
-> filtered, blackholed, or service not responding
HTTP 401
-> TCP connection succeeded
-> HTTP service respondedThis effectively creates a small internal network oracle.
I didn't need to aggressively scan anything.
A handful of controlled tests were enough to prove that the server could distinguish internal network states.
That distinction was also useful when proving the redirect bypass.
Compare:
Direct internal URL
-> "private address prohibited"Direct internal URL
-> "private address prohibited"with:
Public URL -> 302 -> same internal URL
-> "connection timed out"Public URL -> 302 -> same internal URL
-> "connection timed out"The second error only makes sense if execution got past the original URL validation and reached the networking layer.
Good SSRF reports are often built from these kinds of differentials.
One vulnerability became several sink classes
By the end of the investigation, I no longer thought of this as "the vulnerable OIDC URL."
There were several categories:
1. URL with private-IP validation
-> bypassable through redirects
2. URL with no equivalent validation
-> direct internal destination accepted
3. Runtime OAuth/OIDC fetch
-> attacker-selected response consumed as claims
4. Integration/API fetch
-> server-side request + response-body reflection
5. Proxy verification functionality
-> internal connectivity/status oracle1. URL with private-IP validation
-> bypassable through redirects
2. URL with no equivalent validation
-> direct internal destination accepted
3. Runtime OAuth/OIDC fetch
-> attacker-selected response consumed as claims
4. Integration/API fetch
-> server-side request + response-body reflection
5. Proxy verification functionality
-> internal connectivity/status oracleDifferent features.
Different HTTP clients.
Different validation.
Same underlying lesson:
The application had multiple ways to turn configuration data into outbound server requests, but the SSRF policy was not centralized.
I initially undersold the bug
My first version of the report was deliberately conservative.
At that stage I had proved:
server-side request
+
private-IP protection bypass
+
internal connection attemptserver-side request
+
private-IP protection bypass
+
internal connection attemptI had not yet proved the interesting runtime path.
So I framed the impact around authenticated SSRF rather than claiming hypothetical credential theft or internal compromise.
Then I kept investigating.
Once I found the login flow that triggered the OIDC requests, the story changed substantially.
I could now prove that the attacker-controlled URLs were used at runtime and that returned data entered the application's identity-processing pipeline.
Later, the separate response-reflection sink strengthened the case further.
I think this is an important bug bounty lesson:
Don't inflate the first result. Improve the evidence instead.
A statement like:
"This could potentially read AWS credentials and lead to RCE."
is weak if you can't demonstrate it.
A statement like:
"Here is the request leaving the backend, here is the direct-vs-redirect differential, here is the controlled response, and here is the same canary appearing in the application."
is much harder to argue with.
The remediation was more interesting than "block 127.0.0.1"
Because the issue existed across multiple fetch paths, a simple string blacklist wouldn't have solved it.
The important protections were architectural:
1. Parse the URL.
2. Allow only expected schemes.
3. Resolve the hostname.
4. Reject loopback, private, link-local, multicast,
and other prohibited ranges.
5. Connect only to the validated address.
6. If redirects are allowed:
validate EVERY redirect destination again.
7. Consider disabling redirects entirely where they
provide no legitimate functionality.
8. Apply the same URL policy to every backend HTTP client.
9. Don't return arbitrary upstream response bodies
inside application errors.1. Parse the URL.
2. Allow only expected schemes.
3. Resolve the hostname.
4. Reject loopback, private, link-local, multicast,
and other prohibited ranges.
5. Connect only to the validated address.
6. If redirects are allowed:
validate EVERY redirect destination again.
7. Consider disabling redirects entirely where they
provide no legitimate functionality.
8. Apply the same URL policy to every backend HTTP client.
9. Don't return arbitrary upstream response bodies
inside application errors.The redirect rule deserves emphasis.
This is unsafe:
validate(initial_url)
http_client.get(initial_url, follow_redirects=True)validate(initial_url)
http_client.get(initial_url, follow_redirects=True)A safer model is:
url = initial_url
for redirect in allowed_redirects:
destination = resolve_and_validate(url)
response = request(destination)
if not response.is_redirect:
return response
url = response.location
raise TooManyRedirects()url = initial_url
for redirect in allowed_redirects:
destination = resolve_and_validate(url)
response = request(destination)
if not response.is_redirect:
return response
url = response.location
raise TooManyRedirects()Validation has to follow the actual network destination, not just the string originally supplied by the user.
What I took away from this hunt
The first outbound request was only the beginning.
The useful questions were:
Does the backend really fetch it?
Which URL fields are fetched?
When are they fetched?
Do all fields use the same validator?
Is validation performed before or after DNS resolution?
Does the HTTP client follow redirects?
Are redirect destinations revalidated?
Can HTTP be used instead of HTTPS?
Can alternate IP representations bypass an edge filter?
What headers does the server send?
What happens to the response body?
Can the response be surfaced back to me?
Do errors create a network oracle?
Does another feature implement the same behavior differently?Does the backend really fetch it?
Which URL fields are fetched?
When are they fetched?
Do all fields use the same validator?
Is validation performed before or after DNS resolution?
Does the HTTP client follow redirects?
Are redirect destinations revalidated?
Can HTTP be used instead of HTTPS?
Can alternate IP representations bypass an edge filter?
What headers does the server send?
What happens to the response body?
Can the response be surfaced back to me?
Do errors create a network oracle?
Does another feature implement the same behavior differently?The biggest lesson for me was that SSRF hunting is often less about finding a URL parameter and more about following the URL through the application's architecture.
I started with:
"Does this JWKS field cause a request?""Does this JWKS field cause a request?"That eventually became:
configuration
->
URL validation
->
redirect handling
->
multiple HTTP clients
->
runtime OAuth requests
->
internal network access
->
response processing
->
response reflectionconfiguration
->
URL validation
->
redirect handling
->
multiple HTTP clients
->
runtime OAuth requests
->
internal network access
->
response processing
->
response reflectionAnd that is where the interesting bug was hiding.
Never stop at "the server made a request."
Figure out where that request can go, what it carries, what comes back, and which other parts of the application make the same trust decision differently.