Exact Match or Nothing: Securing SSO Redirects Before a Token Exists
Technology Blogs

Exact Match or Nothing: Securing SSO Redirects Before a Token Exists

Rishita Pathak
Trainee Software Engineer

A small redirect bug, three services, and why startsWith is not a security check.

Single sign-on flows usually end the same way: the user logs in, and the identity provider sends them back to the app that asked, along with a session. The URL they’re sent back to is often just a query parameter. In our case, nobody was checking that parameter, and that meant anyone could receive a user’s tokens by sending them a link.

This post covers how we found the gap, why the obvious fixes don’t work, and the pattern we used instead: approve the destination before creating the token, using exact matching on a canonical URL.

Background

Our internal platform has its own login page (Google sign-in or an email OTP). Other internal apps use it for SSO. They send users to the login page with a redirect_uri, and after login the user lands on the app’s callback page with a session:

/login?redirect_uri=https://app.example.com/sso/callback

Three components are involved. The web app (React) hosts the login page. The identity service (Spring Boot) verifies users and creates access and refresh tokens. The Core API (Spring Boot) holds admin-managed settings.

The problem

During a review of the login flow, we traced what happens to redirect_uri. The frontend’s handling came down to this:

if (searchParams.get('redirect_uri') && searchParams.get('source')) {
navigate(`/sso/success?eid=${searchParams.get('redirect_uri')}`, {
state: { accessToken, refreshToken, email, name },
});
}

Whatever URL arrived in the query string received the new session. An attacker does not need direct entry or breach into our infrastructure to cause harm. They only need a victim to click a link.

The victim logs in on our real page, and our own frontend hands the session to the attacker.
Figure 1. The victim logs in on our real page, and our own frontend hands the session to the attacker.

This is an open redirect, a well-known weakness (OWASP has a cheat sheet on it). Here it’s more serious than usual, because the redirect carries credentials with it.

Why nobody noticed

  • Every login worked: A redirect flaw is invisible as long as everyone uses legitimate links.
  • It looked guarded: The source parameter reads like a gate, but the code only checked that it was present. Adding &source=x gets past it.
  • Nobody owned the decision: The frontend chose the destination, the identity service created the tokens, and the Core API held the configuration. Each could reasonably assume someone else was validating the URL.

Tracing further showed three code paths that ended in an SSO redirect: Google sign-in, OTP sign-in, and a silent token refresh when an already logged-in user opens an SSO link. The last one never shows a login form, so a fix that only covered the form would have missed it.

That also answered where the fix belongs. Tokens are created in the identity service, so the destination must be approved there, before the token exists. A check in the browser runs after the tokens have been issued.

Why the obvious fixes fail

The first fix most people reach for is a string check. Suppose the approved callback is https://app.example.com/sso/callback:

Prefix and substring checks let attacker URLs through yet reject harmless variants of the real URL
Figure 2: Prefix and substring checks let attacker URLs through, yet reject harmless variants of the real URL.
  • startsWith accepts app.example.com.evil.com (a different host) and app.example.com@evil.com, where everything before @ is a username and evil.com is the real host.
  • contains accepts our host hidden in someone else’s path or query string.
  • Suffix or regex rules trust forgotten subdomains, and an unescaped . in a regex matches any character.

String checks also reject things they shouldn’t: HTTPS://APP.example.com/… is the same URL, but a case-sensitive prefix check refuses it. The OAuth 2.0 security best-practice document (RFC 9700) is clear on this: redirect URIs should be matched exactly, never by pattern.

Want Engineering That Treats Security as a First-class Design Constraint? Talk to Our Team.

The fix

An admin keeps a list of approved callback URLs in Settings. When a login includes a redirect, the identity service asks the Core API to verify it before creating any token:

The redirect is approved in the shaded area before any token exists
Figure 3: The redirect is approved in the shaded area, before any token exists.

Four rules define the behavior:

  1. Approve before creating the token. Code exchange → identity → redirect check → user validation → tokens.
  2. Canonicalize, then compare with equals. No prefixes, wildcards or regex.
  3. Fail closed. No approved URLs, no match, or a failed verify call all lead to the same result: login refused, no tokens.
  4. Trust the backend’s answer. The frontend redirects only to the URL the backend returns, never to the raw query parameter.

Logins without a redirect_uri are unchanged.

Implementation

Canonicalizing URLs

Canonicalization turns every spelling of the same URL into one string, so that plain equality becomes a safe comparison:

Harmless differences are normalized anything that changes the destination is kept
Figure 4 Harmless differences are normalized; anything that changes the destination is kept.
public String normalize(String url) {
  if (url == null || url.isBlank()) return null;
  URI uri;
  try {
    uri = new URI(url.trim()).normalize(); // resolves ./ and ../
  } catch (URISyntaxException e) {
    return null;
  }
  if (uri.getScheme() == null || uri.isOpaque() || uri.getHost() == null
      || uri.getRawUserInfo() != null) return null; // blocks a@evil.com
  String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
  if (!scheme.equals("http") && !scheme.equals("https")) return null;
  StringBuilder out = new StringBuilder(scheme).append("://")
      .append(uri.getHost().toLowerCase(Locale.ROOT));
  if (uri.getPort() != -1 && uri.getPort() != defaultPort(scheme)) {
    out.append(':').append(uri.getPort());
  }
  String path = uri.getRawPath();
  out.append(path == null || path.isEmpty() ? "/" : path);
  if (uri.getRawQuery() != null) out.append('?').append(uri.getRawQuery());
  if (uri.getRawFragment() != null) out.append('#').append(uri.getRawFragment());
  return out.toString();
}
public boolean matches(String requested, String approved) {
  String canonical = normalize(requested);
  return canonical != null && canonical.equals(normalize(approved));
}

A handful of decisions are made intentionally:

  • Only the scheme and host components are converted to lowercase.
  • User-info and non-http(s) schemes (such as javascript:) are rejected outright.
  • Invalid input returns null, and null never matches, so anything we can’t parse is refused.

The Settings screen uses the same method: invalid URLs are rejected with a 400, and valid ones are stored in canonical form.

Placing the check

In the identity service, a single method runs at the start of all three token paths. It returns the approved URL, or throws a 403 so no token is ever created. Two details make this friendlier for users:

  • In the OTP path, the check runs before the OTP is consumed, so a bad link doesn’t use up the user’s code.
  • If an already logged-in user opens an unapproved link, the frontend shows “This app is not allowed to receive your login” and keeps their session.

The fake source gate was removed, so nobody mistakes it for security later.

Storing approved URLs

Approved URLs live in their own table with soft delete. A partial unique index blocks duplicate active entries but still lets an admin re-add a URL they deleted earlier:

CREATE UNIQUE INDEX uk_sso_setting_active_url 
    ON sso_setting (sso_url) WHERE is_deleted = FALSE;

Because URLs are stored canonicalized, HTTPS://APP.example.com/cb and https://app.example.com/cb collide on this index instead of being saved twice.

Rolling it out

Deploying the services in dependency order kept login working at every step: first the Core API (a new endpoint nothing calls yet), then the identity service (which checks only when the new, optional field is present), then the web app. Because the system fails closed, saving the approved URLs has to be part of the release checklist. Until an admin does it, every SSO redirect is refused.

Validation

We ran the canonicalizer against the approved URL https://app.example.com/sso/callback:

Requested URLResultReason

HTTPS://APP.Example.com/sso/callback

✅ Allowed

Case is normalized

https://app.example.com:443/sso/callback

✅ Allowed

Default port dropped

https://app.example.com/sso/callback/

❌ Refused

Different path

https://app.example.com/sso/callback?x=1

❌ Refused

Extra query

http://app.example.com/sso/callback

❌ Refused

Different scheme

https://app.example.com.evil.com/sso/callback

❌ Refused

Different host

https://app.example.com@evil.com/sso/callback

❌ Refused

User-info

javascript:alert(1)//app.example.com/sso/callback

❌ Refused

Not http(s)

A table like this converts directly into a JUnit @ParameterizedTest, which is the cheapest way to stop anyone from “simplifying” the check back to startsWith.

The end-to-end check is quick. Log in through the approved URL and the app receives the session. Change one character of redirect_uri and the login is refused with no tokens issued. Log in without redirect_uri and nothing has changed.

Key takeaways

  1. Follow the tokens. List every place a credential is created and every place it’s sent. Bugs live in the gap between the two lists.
  2. Validate where the asset is created. If the destination isn’t approved, the token should never exist.
  3. Canonicalize, then use equals. Redirect bypasses exploit the difference between how a URL looks and how it parses.
  4. Checking that a parameter exists isn’t validation. Remove fake gates so nobody relies on them.
  5. Fail closed, and document it. A secure default that isn’t in the release notes will be reported as an outage.

Conclusion

The final change was small: one utility, one endpoint, and one check in the right place. The hard part was noticing the bug, because nothing looked wrong. If your application accepts a redirect_uri, returnUrl or next parameter, take ten minutes to find out who validates it, and whether that happens before or after the token exists.

Rishita Pathak

Rishita Pathak

Trainee Software Engineer

Connect Now

Rishita is a Trainee Software Engineer with a background in Computer Science and Data Science. She is interested in backend and frontend technologies, and machine learning. She enjoys solving real-world development challenges, debugging complex issues, and exploring practical solutions that improve application reliability and user experience.

Share This Blog

Read More Similar Blogs

Let’s #Transform Healthcare,# Together.

Partner with us to design, build, and scale digital solutions that drive better outcomes.

Location

Global Tech Teams LLC, 525 Washington Blvd, Industrious at Newport Tower, Jersey City, NJ 07310, United States.

Contact

+1 408 786 5974
contact@mindbowser.com
BOOK A QUICK CONSULTATION

Have a Healthcare Project in Mind?

Let’s discuss your goals, workflows, and next steps in a focused consultation call.

Calendar icon Schedule a Call

Contact form