Tales of OIDC & OAuth Security: What It Takes to Trust a Token

This post is for Day 22 of Mercari Advent Calendar 2025, brought to you by @Kahla from the Mercari Product Security team.

In this article, we will explore OIDC and OAuth flows, examine common related attacks, and discuss practical hardening strategies.

Background

Recently, in an initiative to improve the security of our OIDC server and identity flows, the product security team collaborated with the identity provider (IDP) team on threat modeling and knowledge-sharing sessions. As I was the most involved in this project, I had the opportunity to deepen my knowledge of the different IDP flows. I thought this article could be a great opportunity to share our takeaways and a security testing guide for similar systems.

Overview of OIDC and OAuth2 flows

Before diving deeper into the security aspects, let’s start with a quick reminder about OAuth2.0 and OIDC. Historically, the OAuth protocol was first introduced as an industry-standard authorization protocol, allowing third-party applications to access a user’s resources with limited permissions without requiring direct access to the user’s credentials. As OAuth was mainly meant for authorization, OIDC came to fill in the gap and build the identity layer on top of OAuth2.0.

OIDC introduced the ID token, which is a JWT containing identity claims used to identify the user. There is a common misconception that OAuth can be used for authentication. Many applications attempt to work around this limitation, but these approaches often introduce design flaws and security issues. The root cause is the nature of the access token: it isn’t standardized and is intended solely for accessing protected resources, not for identifying users.

To make things clear, below is a sequence diagram for a regular OIDC flow:

In Step 3, the client redirects the user’s browser to the authorization server’s /authorize endpoint with the required parameters (such as client_id, redirect_uri, response_type=code, scope, state, nonce, etc.). The user authenticates on the OIDC/authorization server side.
After successful authentication (Step 4), the authorization server issues a short‑lived authorization code and returns it to the client via the callback endpoint, which must match the redirect_uri that was sent in the initial request.
The client backend then exchanges this authorization code at the token endpoint for tokens: in OIDC, an id_token and an access_token (and possibly a refresh_token); in pure OAuth 2.0, only an access_token is returned.

Compared to a plain OAuth 2.0 flow, the main differences in OIDC are the presence of the id_token (for authentication) and the use of OIDC-specific scopes such as openid and profile in the scope parameter.

OIDC Flow Security

In this section we will go over the most important security related components in OIDC flows and discuss the related attacks and mitigations.

State Parameter

The state parameter was introduced to protect against Cross-Site Request Forgery (CSRF) attacks. It is generated before redirecting the user to the /authorize endpoint and stored on the client side. When the user returns to the callback page, the received state value is compared with the originally stored one to ensure the request is legitimate.

If the state parameter is absent or lacks proper verification, an attacker can lure the user into visiting a callback page with the attacker’s code and get them logged in to the attacker’s account. In some cases, an attacker may even be able to carry out more severe CSRF attacks.

It’s also common to see additional information encoded into the state parameter, which may represent a risk when it’s not verified correctly and an attacker can modify it.

The responsibility of securely generating and verifying the state parameter falls on the client application.

Redirection Behavior (redirect_uri)

The redirect_uri parameter is used by the OIDC server to redirect the user back to the client application’s callback endpoint. This parameter should be strictly verified against a pre-configured whitelist. It’s preferred to have strict comparison here for both the application’s domain and endpoint. The main reason is to avoid common URL comparison pitfalls such as:

  • Relying on endsWith() or startsWith() style logic: This is usually bypassable by registering domains similar to attacker-mercari.com or mercari.com.attacker.com.
  • Loose comparison of the path part: A common mistake is to assume a redirect is safe as long as the path begins with the expected prefix on the same domain (e.g., allowing anything that starts with /callback). This becomes dangerous when the application contains an open redirect somewhere else on the site.

Example:
Suppose the authorization server validates that the redirect_uri starts with https://example.com/ and therefore accepts:
https://example.com/shop?next=https://attacker.com
If /shop contains an open redirect via the next parameter, the authorization response is first sent to a legitimate endpoint on example.com, but then immediately redirected to https://attacker.com.

In OIDC Hybrid Flow (e.g., response_type=code id_token), the authorization server returns some tokens directly in the URL fragment (#id_token=…). Fragments are handled entirely in the browser and survive redirects, even across open redirect chains.
As a result, if your redirect_uri validation can be bypassed through an open redirect, the ID token included in the fragment can be carried all the way to the attacker’s domain, leaking it without ever touching your own callback handler.

The following diagram explains this attack scenario:

These are only basic examples; multiple other bypasses are available. The PortSwigger article on URL validation bypasses can be a good reference The correct validation of redirect_uri is the authorization server responsibility.

Nonce Value

Nonces (“numbers used once”) are specific to OIDC flows and are designed to protect against replay attacks in which an attacker attempts to reuse a previously issued ID token. When initiating the authorization request, the client generates a cryptographically random nonce value and stores it.

During the callback, the client must verify that the nonce claim inside the returned ID token exactly matches the stored value. This ensures that the token was generated specifically in response to this authorization request and cannot be replayed from another session or user.

Importantly, nonces must be single-use: once a nonce has been validated, it should be discarded so it cannot be matched again in a future flow. If nonce validation is missing, weak, or allows reuse, an attacker can replay an ID token issued in a different context, effectively “recycling” a past authentication and impersonating the original user.

Proof Key for Code Exchange (PKCE)

The Proof Key for Code Exchange (PKCE) flow is primarily designed to mitigate authorization code interception. These attacks are particularly common in mobile applications for example, in cases of deep-link hijacking. PKCE introduces an extra layer of defense through a code verification mechanism.
The client app begins by generating a cryptographically random code_verifier. It then derives a hashed value from it, known as the code_challenge, and sends this challenge to the OIDC server during the initial authorization request.
Later, during the token exchange phase, the client must provide the original code_verifier. The OIDC server re-computes the hash and compares it to the previously received code_challenge. If they match, the server knows that the party performing the exchange is the same one that initiated the flow. This ensures that even if an attacker intercepts the authorization code, they still cannot exchange it for tokens because they do not possess the original code_verifier.
It’s interesting to note that even though PKCE prevents code interception in most cases, if the attacker manages to trigger the OIDC flow using their own controlled link (getting the user to click or be redirected to their URL), the attacker will be able to use the intercepted code successfully, as the initial code_challenge value was attacker-controlled. However, such an attack is quite hard to apply in real life given the multiple requirements.

The blue-highlighted sections represent the PKCE-specific components of the flow: the creation and transmission of the code_verifier and code_challenge, and later, the server-side verification of the original code_verifier during the token exchange.

Demonstrating Proof of Possession (DPoP) token

Demonstrating Proof of Possession (DPoP) is mainly used to protect against token theft. It’s a JWT sent along with every request to prove possession of the access token. This is cryptographically ensured by the fact that the app first generates a key pair where the public key will be shared with the authorization server and the private key will be used to sign the DPoP JWT.

The public key will be used to verify the DPoP token for every request, proving possession of the token. One crucial step is to bind the access token with the DPoP public key when it’s first issued.

A lot of implementations ignore this step, making the presence of DPoP useless, as the attacker can just forge a DPoP token using their own key pair and reuse the stolen access token depending on the scenario.

iss Parameter

The iss (issuer) parameter is usually returned by the authorization server in order for the client to confirm the expected authorization server. This is mainly introduced to prevent mix-up attacks, which happen when the client can’t determine which authorization server to use to exchange the code value when multiple authorization servers are implemented (e.g. “Sign in with Google”, LINE, etc. on the same website).

Such attacks aim to leak the code value to a malicious attacker-controlled authorization server. They are quite common within applications involving multiple users or organizations sharing the same callback endpoint while allowing registration of a custom identity provider (for example, a SaaS product giving organizations the option to enable custom SSO).

Exploiting this issue differs by case; however, it’s often related to understanding how the application logic decides which OIDC server to use when exchanging the code value. A great description of an attack example is described in the RFC here.

The following sequence diagram illustrates the overall attack idea. The ways to confuse the client still depend on the situation and implementation.

To mitigate mix-up attacks, the client must ensure that authorization responses are bound to the correct authorization server. This typically involves validating the issuer (iss) value returned in the response and rejecting any mismatch. Using distinct redirect URIs per provider and relying on trusted metadata further reduces the risk of confusing authorization servers.

Hardening of OIDC/OAuth Flows

Understanding the previous attacks, why every parameter exists, and making sure to implement them in the correct way will already help mitigate most of the common issues. If you are seeking to protect a highly sensitive API, then FAPI 2.0 security profiles might be a good resource to check. They define security improvements and mitigations based on the following attacker model, covering protections even at the network layer and recommendations per component.

One interesting hardening extension is adopting Pushed Authorization Requests (PAR). Instead of navigating directly to the /authorize endpoint with all the needed parameters, a POST request is first sent with these parameters to the authorization server. The server then returns a request_uri that will be used afterward in the /authorize request.
This moves sensitive request details from the public browser (front channel) to a secure server-to-server (back channel), preventing exposure, tampering, and URL length issues. This is only a general idea about PAR, as it also involves other checks and requirements that need to be satisfied. More details are in the official RFC page.

An overall diagram is presented below for the PAR extension:

Summary

We discussed the main attack vectors that can affect OAuth2.0/OIDC flows and how each parameter and component can help mitigate them. However, to keep the article concise, some in-depth details were intentionally left out.

At their core, OIDC and OAuth security mechanisms revolve around establishing and preserving three key assurances:

  • Is the user (resource owner) really the legitimate user?
    This addresses replay protection, token theft, DPoP, nonces, and every mechanism that ensures tokens cannot be reused or repurposed by attackers.

  • Did the user intentionally perform the action?
    Answering this mitigates CSRF and prevents malicious sites from initiating or influencing an OIDC/OAuth flow without the user’s awareness.

  • Is the client application truly the one that should receive the tokens?
    This includes validating redirect URIs, enforcing PKCE correctly, and ensuring state/nonce correlation.

All of these assurances rely on one more important consideration:

  • Is the environment itself secure from client-side vulnerabilities (like XSS) that could undermine the entire flow?

If any of these assurances fail, the OAuth2.0/OIDC flow becomes susceptible to compromise. When all are satisfied, the system maintains strong guarantees about identity, intent, and trust between the user, the client, and the authorization server.

After working on this initiative, it has become clear to me that having a clear threat model before beginning any assessment is really important, especially when dealing with complex flows. It helps ensure no attack vector is missed and is also a great way to learn more from the owner team. Big kudos to the @IDP team members for the amazing collaboration.

If you are interested in learning and working on similar fun projects, feel free to check our careers page for job openings!

Tomorrow’s article will be by @Sneha & @Yu. Look forward to it!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加