Single Sign-On (SSO) is the backbone of the modern web, offering a seamless “Log in with…” experience that users have come to trust. However, behind that convenience lies the OAuth 2.0 protocol that, if misconfigured, can turn a security feature into a wide-open back door.
In this research, we explored several open-source projects and authentication libraries to see how theory meets practice. What we found was a series of critical implementation flaws, ranging from hardcoded cryptographic keys to predictable state tokens, that led to 1-click account takeovers and session injection attacks. The research concluded with no fewer than 10 vulnerabilities, some in projects such as Langfuse, Fastapi-users, and the Authlib library. The table below showcases the vulnerabilities that have a CVE assigned at this moment.
Project | CVE | Affected Versions |
CVE-2025-65107 | >=2.95.0, <2.95.12; >= 3.17.0, <3.131.0 | |
CVE-2025-68158 | <v1.6.6 | |
CVE-2025-14546 | <0.19.0 | |
CVE-2025-68481 | <15.0.2 |
OAuth2.0 general overview
OAuth2.0 (Open Authorization) is an open standard protocol for access delegation. It is commonly used by Internet users to grant websites or applications access to their information on other websites, without giving them the passwords. OAuth allows App A to access a user's data from App B using access tokens that have limited scopes, limited lifespans, and can be revoked.
The key roles in an OAuth transaction are:
Client application - The website or web application that wants to access the user's data.
Resource owner - The user whose data the client application wants to access.
OAuth service provider - The website or application that controls the user's data and access to it. They support OAuth by providing an API for interacting with both an authorization server and a resource server.
Although not originally intended for this purpose, OAuth has evolved into a means of authenticating users as well. Providers such as Google, GitHub, and Gitlab use OAuth, allowing client apps to authenticate users using their provider identities. The app will use a limited resource scope to gather relevant information about the user to generate the user profile and uniquely identify the user in the database. In the context of social login, this data often includes only basic details such as the user’s name, email address, and profile picture.
Before examining some of the vulnerabilities in OAuth implementations, it is helpful to understand the main requests that constitute a typical OAuth flow. While a usual OAuth flow will have more requests (especially between step 1 and 2), there are 4 main requests that OAuth flows must make.

Direction: Client (App) -> Authorization Server via the User (Browser)
The process starts when the user clicks "Log in with Service." The application redirects the user's browser to the authorization server's authorization endpoint.
Parameter | Type | Description |
| Required | Tells the server what flow is being used. For this flow, the value is almost always |
| Required | The public identifier for the app (issued when the developer registered the app). |
| Optional* | The URL where the user should be sent back to after logging in. |
| Optional | A space-separated list of permissions requested (e.g., |
| Recommended | A random string generated by the app to maintain state between the request and callback. Critical for preventing CSRF attacks. |
| Optional | Used in PKCE (Proof Key for Code Exchange). A hashed version of a secret created by the app for this specific transaction. |
| Optional | The method used to hash the challenge (usually |
While not mentioned in the OAuth 2.0 RFC, another relevant parameter is prompt. This parameter controls whether the user is prompted to provide consent to authorize the client app. If the user authorized the app in the past and the prompt parameter is missing, no consent prompt will show up. If the prompt parameter is present and set to consent, then this prompt will show up even if the app was authorized in the past. This behaviour is relevant to the article's later content.
2. The authorization response

Direction: Authorization Server -> Client (App) via User (Browser)
If the user accepts authorizing the app with the requested permissions, the authorization server redirects the user back to the redirect_uri, appending the authorization code.
Parameter | Description |
| The temporary Authorization Code. It is short-lived (often expires in minutes; the RFC mandates it to expire in a maximum of 10 minutes) and single-use. |
| The same string that was sent in Step 1. The Client must verify that this matches what it sent to ensure the request wasn't hijacked. |
3. The token exchange request

Direction: Client (App) -> Authorization Server (Back-channel)
Now the Client application talks directly to the authorization server (server-to-server, not via the user's browser) to exchange the temporary code for a real Access Token.
4. The token response

Direction: Authorization Server -> Client (App)
If the code is valid and the client credentials (or PKCE verifier) are correct, the server responds with the keys to the castle.
As the last two requests are made server-side, the first two are more interesting, from an attacker's perspective, as they can be controlled by a client.
OAuth2.0 misconfigurations
While OAuth 2.0 is robust by design, it is a complex protocol with many moving parts. We'll explore common implementation flaws/misconfigurations seen in real applications. Some of the vulnerabilities stem from classical, well-known issues, such as hardcoding keys.
One example involves an open source product data management platform built on a popular PHP web framework. The platform uses Passport to implement OAuth in its Admin API. In this case, the platform acts as an authorization server, authorizing admins/integrations to gain access to the platform. In this codebase, Passport is configured to use a hardcoded key pair to sign the JWTs sent in step 4: "The Token Response".
To authenticate admins to the Admin API, the platform uses JWTs. JWT (JSON Web Token) is an Internet standard for creating data with an optional signature and/or encryption, whose payload contains JSON that asserts a number of claims. The tokens are signed either with a private key or a public/private key pair. The claims they hold typically include data such as the user for whom the token was issued, when the token was issued, and when it expires, as well as any other data the app may need.
When a JWT is signed using a public/private key pair, the app that issues the tokens creates a signature of the token using the private key. Then, the token can be checked for authenticity using the public key. If an attacker gets a hold of the private key, they can sign their own tokens, tricking the app into accepting forged tokens.
In a JSON Web Token (JWT), jti and aud are registered claims. jti (JWT ID) is used as a unique identifier for each token. This makes it harder for attackers to forge tokens, as they need to know a valid jti to forge a valid token. Also, JWTs are stateless, meaning they normally can't be revoked until they expire. This claim offers applications the ability to revoke tokens. The aud value identifies the intended recipient of the token. In our case, it identifies the tenant for which the token was issued.
This means that an attacker with limited access to the Admin API can leak valid aud and jti values and use them to forge tokens (using the hardcoded keypair from above), to grant themselves unlimited permissions to the app, or to impersonate other users.
While hardcoded credentials can always lead in high-impact vulnerabilities, we'll discuss more OAuth-specific vulnerabilities.
Common "state" problems
Looking at the first request in the OAuth flow, response_type, redirect_uri, scope, and state are all important parameters to ensuring the security of an OAuth transaction. For example, redirect_uri can be used to determine where the authorization response (often called a callback) sends the grant code. If an attacker can set this to an attacker-controlled location and convince a user to go through the flow, they can steal the user's grant code and use it to take over the user's account, or extract sensitive data about the user from the OAuth provider.
However, most parameters are validated by the authority server. There is one parameter that has to be properly generated and validated by the client app, and that is the state. The state parameter prevents CSRF login attacks. Login CSRF can result in account takeover, so this parameter is crucial to the flow.
Overwriting secure defaults by accident
One interesting vulnerability we found, and that started this research idea, was CVE-2025-65107 - a 1-click account takeover in Langfuse affecting versions from 2.95.0 to before 2.95.12 and from 3.17.0 to before 3.131.0. Langfuse is an open source LLM engineering platform providing LLM Observability, metrics, evals, prompt management, playground, and datasets.
Langfuse provides a straightforward way to integrate a comprehensive list of SSO providers, such as Google, GitHub, GitLab, and Auth0, using the NextAuth.js library. To add a supported provider, one only needs to add 2-3 environment variables to the deployment. For example, to add Google, only AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET are required, as per the documentation. The redirect_uri is static, and it is handled by the NextAuth library. Each enabled provider has its own callback endpoint, and the URL will have the following form: https://langfuse.instance/api/auth/callback/<PROVIDER>.
In addition to each required environment variable, a list of optional configuration options is available. One of them is AUTH_<PROVIDER>_CHECKS.
| Configure the authentication checks. Supported values: |
https://langfuse.com/self-hosting/security/authentication-and-sso#additional-configuration
When Langfuse configures a new SSO provider, it sets the checks attribute to the env.AUTH_<PROVIDER>_CHECKS value, which by default is undefined.
web/src/server/auth.ts:213-225
The env schema keeps that undefined value intact - zAuthChecks only transforms defined strings and otherwise returns undefined.
web/src/env.mjs:17-21
With provider.checks missing "state", the sign-in handler skips creating the state cookie and omits the state query parameter on the authorization URL.
next-auth/packages/next-auth/src/core/lib/oauth/authorization-url.ts:55-62
This creates a vulnerable OAuth flow that's vulnerable to CSRF attacks. We can see how the app handles a login CSRF by looking at the NextAuth.js code.
During a successful CSRF attack, the GET request to the callback api endpoint is made in a context where the __Secure-next-auth.session-token cookie is set (to a session of the victim).
getSessionAndUser reads that cookie and exposes its value to the callback handler. The decoded JWT loads the victim user from Prisma (the ORM used by Langfuse) before any linking logic runs.
next-auth/packages/core/src/lib/actions/callback/handle-login.ts:70-91
The handler checks whether the incoming SSO account is already known via getUserByAccount. It returns null (a personal identity is not yet linked). Because the victim user was recovered from the session token, the code enters the “link to current session user” branch (regardless of whether allowDangerousEmailAccountLinking is set to True or not).
next-auth/packages/next-auth/src/core/lib/callback-handler.ts:88-163
There, it calls linkAccount({ ...account, userId: user.id }), which invokes the Prisma adapter’s linkAccount function and writes a row to the Account table. After that, the response carries the existing work-session cookie, so the victim remains logged in.
After this step, the attacker's provider identity is permanently linked to the victim's account, resulting in account takeover.
To exploit this, an attacker starts an SSO OAuth flow, but stops it right before making the callback call to Langfuse - /api/auth/callback/<PROVIDER>/?code=<grant_code>. Then, the attacker tricks a logged-in user (via phishing, injecting the URL in an image inside a trace sent to the instance, or a drive-by attack via a malicious website - you may consider iframes as a good attack vector, but they don’t work anymore - we will see later why) to perform a GET request with the attacker's code to the Langfuse callback. Once the request is made, the user has no way to stop the linking or know that it took place.
To fix this issue, Langfuse implemented the following pattern for all provider configurations. This line of code uses ternary operations to set the checks value either to the value of env.AUTH_CUSTOM_CHECKS if such a value exists, or to {}. If {} is passed, NextAuth will apply the default value for checks, which includes both state and pkce, making the flow secure.
Lack of state validation
Some OAuth libraries explicitly transfer the responsibility of generating and validating the state parameter to the applications that use them; however, some handle the state creation but don't validate this token, nor do they provide solutions for developers to validate the state themselves. Without the validation of the state token, the flow becomes vulnerable to CSRF attacks, which in turn can result in account takeover.
For example, fastapi-sso is a Python library that enables SSO for the most common providers, such as Facebook login, Google login, etc. In versions up to 0.19.0, this library is vulnerable to a login CSRF vulnerability (CVE-2025-14546), which could lead to account takeover in apps using the library.
fastapi-sso generated a random OAuth state but never validated it, so login CSRF was trivial. get_login_url simply forwarded whichever state string is passed (or auto-filled using _generated_state, a parameter that holds a random value generated for every login request ) into the authorization request without persisting it anywhere, server/client-side.
fastapi_sso/sso/base.py:303-319
During the callback, the library copied the attacker-supplied state query parameter into _state and proceeded. The random value produced in generate_random_state was never compared to anything.
If an app uses the vulnerable fastapi-sso versions and has code logic similar to the one existing in the NextAuth library when it comes to merging the accounts in case of a callback request while a user is logged in, then this will lead to account takeover.
To secure this OAuth flow, fastapi-sso now sets the sso_state cookie at the start of the OAuth flow, with its value set to the state token. When a callback request is made, fastapi-sso checks whether the sso_state cookie exists and if it is identical to the state parameter from the callback request. Because the state token is a randomly generated value, this secures the flow against CSRF attacks.
fastapi_sso/sso/base.py:344-414
Wrapping up part 1
In this first part, we've covered the basics of OAuth 2.0 and explored what happens when secure defaults are overwritten or state validation is completely skipped. But what if a state token exists and is validated, yet remains completely predictable? Or what happens when an attacker injects their own session into a custom CLI flow? We will dive into these complex attack vectors, interchangeable state tokens, and essential defense-in-depth mitigations in Part 2 of this series.



