Incomplete flow in the new OAuth 2.1 Provider plugin #2755

Open
opened 2026-03-13 10:18:13 -05:00 by GiteaMirror · 10 comments
Owner

Originally created by @pxtrickb on GitHub (Jan 22, 2026).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create two next.js applications, both with better-auth
  2. On one application implement the new oauth-provider plugin (we'll call this the auth provider)
  3. On the second application implement the generic oauth plugin (we'll call this the auth consumer)
  4. Configure the two to work with eachother
  5. Call authClient.signIn.oauth2 from the auth consumer app.
  6. Try completing the sign in process on the auth provider app. <--- This is where the issue appears.

Current vs. Expected behavior

Current behavior:

When users get redirected to the authentication provider app for completing the sign in process (e.g.: filling an email/password form), they are not redirected back to the authentication consumer app after a valid completion. Instead, the form's usual logic takes over and redirects user's to the "dashboard", in my case. But even if I disable the redirect, the page just hangs on the sign in form. The session is created, but the OAuth flow does not go further.

Expected behavior:

After the users successfully sign in, they should be redirected back to the consumer app.

What version of Better Auth are you using?

1.4.17

System info

{
  "system": {
    "platform": "win32",
    "arch": "x64",
    "version": "Windows 11 Pro",
    "release": "10.0.26100",
    "cpuCount": 20,
    "cpuModel": "12th Gen Intel(R) Core(TM) i7-12700H",
    "totalMemory": "15.62 GB",
    "freeMemory": "1.99 GB"
  },
  "node": {
    "version": "v24.0.2",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.4.1"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "16.1.4"
    },
    {
      "name": "react",
      "version": "19.2.3"
    }
  ],
  "databases": [
    {
      "name": "pg",
      "version": "^8.17.2"
    },
    {
      "name": "drizzle",
      "version": "^0.45.1"
    }
  ],
  "betterAuth": {
    "version": "^1.4.17",
    "config": {
      "trustedOrigins": [
        "[REDACTED]"
      ],
      "emailAndPassword": {
        "enabled": true
      },
      "advanced": {
        "cookiePrefix": "[REDACTED]"
      },
      "plugins": [
        {
          "name": "oauthProvider",
          "config": {
            "id": "oauthProvider",
            "options": {
              "codeExpiresIn": 600,
              "accessTokenExpiresIn": 3600,
              "m2mAccessTokenExpiresIn": 3600,
              "refreshTokenExpiresIn": 2592000,
              "allowUnauthenticatedClientRegistration": false,
              "allowDynamicClientRegistration": false,
              "disableJwtPlugin": true,
              "storeClientSecret": "[REDACTED]",
              "storeTokens": "hashed",
              "grantTypes": [
                "authorization_code",
                "client_credentials",
                "refresh_token"
              ],
              "loginPage": "/signin",
              "consentPage": "/consent",
              "scopes": [
                "openid",
                "profile",
                "email",
                "offline_access"
              ],
              "claims": [
                "sub",
                "iss",
                "aud",
                "exp",
                "iat",
                "sid",
                "scope",
                "azp",
                "email",
                "email_verified",
                "name",
                "picture",
                "family_name",
                "given_name"
              ]
            },
            "hooks": {
              "before": [
                {}
              ],
              "after": [
                {}
              ]
            },
            "endpoints": {},
            "schema": {
              "oauthClient": {
                "modelName": "oauthClient",
                "fields": {
                  "clientId": {
                    "type": "string",
                    "unique": true,
                    "required": true
                  },
                  "clientSecret": {
                    "type": "string",
                    "required": false
                  },
                  "disabled": {
                    "type": "boolean",
                    "defaultValue": false,
                    "required": false
                  },
                  "skipConsent": {
                    "type": "boolean",
                    "required": false
                  },
                  "enableEndSession": {
                    "type": "boolean",
                    "required": false
                  },
                  "scopes": {
                    "type": "string[]",
                    "required": false
                  },
                  "userId": {
                    "type": "string",
                    "required": false,
                    "references": {
                      "model": "user",
                      "field": "id"
                    }
                  },
                  "createdAt": {
                    "type": "date",
                    "required": false
                  },
                  "updatedAt": {
                    "type": "date",
                    "required": false
                  },
                  "name": {
                    "type": "string",
                    "required": false
                  },
                  "uri": {
                    "type": "string",
                    "required": false
                  },
                  "icon": {
                    "type": "string",
                    "required": false
                  },
                  "contacts": {
                    "type": "string[]",
                    "required": false
                  },
                  "tos": {
                    "type": "string",
                    "required": false
                  },
                  "policy": {
                    "type": "string",
                    "required": false
                  },
                  "softwareId": {
                    "type": "string",
                    "required": false
                  },
                  "softwareVersion": {
                    "type": "string",
                    "required": false
                  },
                  "softwareStatement": {
                    "type": "string",
                    "required": false
                  },
                  "redirectUris": {
                    "type": "string[]",
                    "required": true
                  },
                  "postLogoutRedirectUris": {
                    "type": "string[]",
                    "required": false
                  },
                  "tokenEndpointAuthMethod": {
                    "type": "string",
                    "required": false
                  },
                  "grantTypes": {
                    "type": "string[]",
                    "required": false
                  },
                  "responseTypes": {
                    "type": "string[]",
                    "required": false
                  },
                  "public": {
                    "type": "boolean",
                    "required": false
                  },
                  "type": {
                    "type": "string",
                    "required": false
                  },
                  "referenceId": {
                    "type": "string",
                    "required": false
                  },
                  "metadata": {
                    "type": "json",
                    "required": false
                  }
                }
              },
              "oauthRefreshToken": {
                "fields": {
                  "token": {
                    "type": "string",
                    "required": true
                  },
                  "clientId": {
                    "type": "string",
                    "required": true,
                    "references": {
                      "model": "oauthClient",
                      "field": "clientId"
                    }
                  },
                  "sessionId": {
                    "type": "string",
                    "required": false,
                    "references": {
                      "model": "session",
                      "field": "id",
                      "onDelete": "set null"
                    }
                  },
                  "userId": {
                    "type": "string",
                    "required": true,
                    "references": {
                      "model": "user",
                      "field": "id"
                    }
                  },
                  "referenceId": {
                    "type": "string",
                    "required": false
                  },
                  "expiresAt": {
                    "type": "date"
                  },
                  "createdAt": {
                    "type": "date"
                  },
                  "revoked": {
                    "type": "date",
                    "required": false
                  },
                  "scopes": {
                    "type": "string[]",
                    "required": true
                  }
                }
              },
              "oauthAccessToken": {
                "modelName": "oauthAccessToken",
                "fields": {
                  "token": {
                    "type": "string",
                    "unique": true
                  },
                  "clientId": {
                    "type": "string",
                    "required": true,
                    "references": {
                      "model": "oauthClient",
                      "field": "clientId"
                    }
                  },
                  "sessionId": {
                    "type": "string",
                    "required": false,
                    "references": {
                      "model": "session",
                      "field": "id",
                      "onDelete": "set null"
                    }
                  },
                  "userId": {
                    "type": "string",
                    "required": false,
                    "references": {
                      "model": "user",
                      "field": "id"
                    }
                  },
                  "referenceId": {
                    "type": "string",
                    "required": false
                  },
                  "refreshId": {
                    "type": "string",
                    "required": false,
                    "references": {
                      "model": "oauthRefreshToken",
                      "field": "id"
                    }
                  },
                  "expiresAt": {
                    "type": "date"
                  },
                  "createdAt": {
                    "type": "date"
                  },
                  "scopes": {
                    "type": "string[]",
                    "required": true
                  }
                }
              },
              "oauthConsent": {
                "modelName": "oauthConsent",
                "fields": {
                  "clientId": {
                    "type": "string",
                    "required": true,
                    "references": {
                      "model": "oauthClient",
                      "field": "clientId"
                    }
                  },
                  "userId": {
                    "type": "string",
                    "required": false,
                    "references": {
                      "model": "user",
                      "field": "id"
                    }
                  },
                  "referenceId": {
                    "type": "string",
                    "required": false
                  },
                  "scopes": {
                    "type": "string[]",
                    "required": true
                  },
                  "createdAt": {
                    "type": "date"
                  },
                  "updatedAt": {
                    "type": "date"
                  }
                }
              }
            }
          }
        }
      ]
    }
  }
}

Which area(s) are affected? (Select all that apply)

Backend, Package

Auth config (if applicable)

import { betterAuth } from "better-auth";
import { oauthProvider } from "@better-auth/oauth-provider";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";

export const auth = betterAuth({
    trustedOrigins: [process.env.NEXT_PUBLIC_URL!],
    database: drizzleAdapter(db, {
        provider: "pg",
    }),
    emailAndPassword: {
        enabled: true,
    },
    advanced: {
        cookiePrefix: "era-id",
    },
    plugins: [oauthProvider({
        disableJwtPlugin: true,
        loginPage: "/signin",
        consentPage: "/consent",
    })],
});

Additional context

Below, I will also provide a snippet of the sign in form submission handler:

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget as HTMLFormElement);
        const email = formData.get("email") as string;
        const password = formData.get("password") as string;

        setLoading(true);
        setError(null);
        const { data, error } = await authClient.signIn.email({
            email,
            password,
        });
        if (error) {
            setError(error.message ?? "Something went wrong");
            setLoading(false);
            return;
        }
        if (data) {
            redirect("/"); // <--- Commenting this line out, will not fix the problem. The page will just hold indefinitely. 
        }
    }
Originally created by @pxtrickb on GitHub (Jan 22, 2026). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Create two next.js applications, both with better-auth 2. On one application implement the new oauth-provider plugin (we'll call this the auth provider) 3. On the second application implement the generic oauth plugin (we'll call this the auth consumer) 4. Configure the two to work with eachother 5. Call `authClient.signIn.oauth2` from the auth consumer app. 6. Try completing the sign in process on the auth provider app. <--- This is where the issue appears. ### Current vs. Expected behavior ### Current behavior: When users get redirected to the authentication provider app for completing the sign in process (e.g.: filling an email/password form), they are not redirected back to the authentication consumer app after a valid completion. Instead, the form's usual logic takes over and redirects user's to the "dashboard", in my case. But even if I disable the redirect, the page just hangs on the sign in form. The session is created, but the OAuth flow does not go further. ### Expected behavior: After the users successfully sign in, they should be redirected back to the consumer app. ### What version of Better Auth are you using? 1.4.17 ### System info ```bash { "system": { "platform": "win32", "arch": "x64", "version": "Windows 11 Pro", "release": "10.0.26100", "cpuCount": 20, "cpuModel": "12th Gen Intel(R) Core(TM) i7-12700H", "totalMemory": "15.62 GB", "freeMemory": "1.99 GB" }, "node": { "version": "v24.0.2", "env": "development" }, "packageManager": { "name": "npm", "version": "11.4.1" }, "frameworks": [ { "name": "next", "version": "16.1.4" }, { "name": "react", "version": "19.2.3" } ], "databases": [ { "name": "pg", "version": "^8.17.2" }, { "name": "drizzle", "version": "^0.45.1" } ], "betterAuth": { "version": "^1.4.17", "config": { "trustedOrigins": [ "[REDACTED]" ], "emailAndPassword": { "enabled": true }, "advanced": { "cookiePrefix": "[REDACTED]" }, "plugins": [ { "name": "oauthProvider", "config": { "id": "oauthProvider", "options": { "codeExpiresIn": 600, "accessTokenExpiresIn": 3600, "m2mAccessTokenExpiresIn": 3600, "refreshTokenExpiresIn": 2592000, "allowUnauthenticatedClientRegistration": false, "allowDynamicClientRegistration": false, "disableJwtPlugin": true, "storeClientSecret": "[REDACTED]", "storeTokens": "hashed", "grantTypes": [ "authorization_code", "client_credentials", "refresh_token" ], "loginPage": "/signin", "consentPage": "/consent", "scopes": [ "openid", "profile", "email", "offline_access" ], "claims": [ "sub", "iss", "aud", "exp", "iat", "sid", "scope", "azp", "email", "email_verified", "name", "picture", "family_name", "given_name" ] }, "hooks": { "before": [ {} ], "after": [ {} ] }, "endpoints": {}, "schema": { "oauthClient": { "modelName": "oauthClient", "fields": { "clientId": { "type": "string", "unique": true, "required": true }, "clientSecret": { "type": "string", "required": false }, "disabled": { "type": "boolean", "defaultValue": false, "required": false }, "skipConsent": { "type": "boolean", "required": false }, "enableEndSession": { "type": "boolean", "required": false }, "scopes": { "type": "string[]", "required": false }, "userId": { "type": "string", "required": false, "references": { "model": "user", "field": "id" } }, "createdAt": { "type": "date", "required": false }, "updatedAt": { "type": "date", "required": false }, "name": { "type": "string", "required": false }, "uri": { "type": "string", "required": false }, "icon": { "type": "string", "required": false }, "contacts": { "type": "string[]", "required": false }, "tos": { "type": "string", "required": false }, "policy": { "type": "string", "required": false }, "softwareId": { "type": "string", "required": false }, "softwareVersion": { "type": "string", "required": false }, "softwareStatement": { "type": "string", "required": false }, "redirectUris": { "type": "string[]", "required": true }, "postLogoutRedirectUris": { "type": "string[]", "required": false }, "tokenEndpointAuthMethod": { "type": "string", "required": false }, "grantTypes": { "type": "string[]", "required": false }, "responseTypes": { "type": "string[]", "required": false }, "public": { "type": "boolean", "required": false }, "type": { "type": "string", "required": false }, "referenceId": { "type": "string", "required": false }, "metadata": { "type": "json", "required": false } } }, "oauthRefreshToken": { "fields": { "token": { "type": "string", "required": true }, "clientId": { "type": "string", "required": true, "references": { "model": "oauthClient", "field": "clientId" } }, "sessionId": { "type": "string", "required": false, "references": { "model": "session", "field": "id", "onDelete": "set null" } }, "userId": { "type": "string", "required": true, "references": { "model": "user", "field": "id" } }, "referenceId": { "type": "string", "required": false }, "expiresAt": { "type": "date" }, "createdAt": { "type": "date" }, "revoked": { "type": "date", "required": false }, "scopes": { "type": "string[]", "required": true } } }, "oauthAccessToken": { "modelName": "oauthAccessToken", "fields": { "token": { "type": "string", "unique": true }, "clientId": { "type": "string", "required": true, "references": { "model": "oauthClient", "field": "clientId" } }, "sessionId": { "type": "string", "required": false, "references": { "model": "session", "field": "id", "onDelete": "set null" } }, "userId": { "type": "string", "required": false, "references": { "model": "user", "field": "id" } }, "referenceId": { "type": "string", "required": false }, "refreshId": { "type": "string", "required": false, "references": { "model": "oauthRefreshToken", "field": "id" } }, "expiresAt": { "type": "date" }, "createdAt": { "type": "date" }, "scopes": { "type": "string[]", "required": true } } }, "oauthConsent": { "modelName": "oauthConsent", "fields": { "clientId": { "type": "string", "required": true, "references": { "model": "oauthClient", "field": "clientId" } }, "userId": { "type": "string", "required": false, "references": { "model": "user", "field": "id" } }, "referenceId": { "type": "string", "required": false }, "scopes": { "type": "string[]", "required": true }, "createdAt": { "type": "date" }, "updatedAt": { "type": "date" } } } } } } ] } } } ``` ### Which area(s) are affected? (Select all that apply) Backend, Package ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth"; import { oauthProvider } from "@better-auth/oauth-provider"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db } from "@/db"; export const auth = betterAuth({ trustedOrigins: [process.env.NEXT_PUBLIC_URL!], database: drizzleAdapter(db, { provider: "pg", }), emailAndPassword: { enabled: true, }, advanced: { cookiePrefix: "era-id", }, plugins: [oauthProvider({ disableJwtPlugin: true, loginPage: "/signin", consentPage: "/consent", })], }); ``` ### Additional context Below, I will also provide a snippet of the sign in form submission handler: ```ts const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget as HTMLFormElement); const email = formData.get("email") as string; const password = formData.get("password") as string; setLoading(true); setError(null); const { data, error } = await authClient.signIn.email({ email, password, }); if (error) { setError(error.message ?? "Something went wrong"); setLoading(false); return; } if (data) { redirect("/"); // <--- Commenting this line out, will not fix the problem. The page will just hold indefinitely. } } ```
GiteaMirror added the plugin label 2026-03-13 10:18:13 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Jan 22, 2026):

It seems you're experiencing an issue with the OAuth 2.1 Provider plugin where the redirection after signing in doesn't occur as expected. Here's a suggestion for troubleshooting this issue:

  1. Check Redirect URI: Ensure that the redirect URI specified in your OAuth provider is correctly set up to point back to your authentication consumer app. This should be configured in both the provider and consumer applications.

  2. Session Management: Verify if your session is correctly managed. Based on your code, make sure storeSessionInDatabase is set to true, especially if you are using a secondary storage configuration. The OAuth flow needs sessions to be correctly stored to work as intended [2].

  3. Debugging Logs: Enable debug logging in your Better Auth configuration to see additional log messages during the OAuth process. This can sometimes reveal what's going wrong in the flow.

  4. Handler Logic: Review the logic in your sign-in form submission handler to ensure it's adequately handling successful sign-ins. Since commenting out the redirect doesn’t resolve the hanging state, you may want to look into session state or await the redirect logic properly.

  5. Consult Documentation: Review the documentation for the new OAuth Provider, as there may be specific requirements or known issues pertaining to your version [2].

If you continue to experience issues after these steps, please tag @bekacru and @ping__ for further assistance from the Better Auth team.

What other integrations or configurations are you considering with Better Auth?

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

@better-auth-agent[bot] commented on GitHub (Jan 22, 2026): It seems you're experiencing an issue with the OAuth 2.1 Provider plugin where the redirection after signing in doesn't occur as expected. Here's a suggestion for troubleshooting this issue: 1. **Check Redirect URI**: Ensure that the redirect URI specified in your OAuth provider is correctly set up to point back to your authentication consumer app. This should be configured in both the provider and consumer applications. 2. **Session Management**: Verify if your session is correctly managed. Based on your code, make sure `storeSessionInDatabase` is set to true, especially if you are using a secondary storage configuration. The OAuth flow needs sessions to be correctly stored to work as intended [[2]](https://github.com/better-auth/better-auth/pull/4163). 3. **Debugging Logs**: Enable debug logging in your Better Auth configuration to see additional log messages during the OAuth process. This can sometimes reveal what's going wrong in the flow. 4. **Handler Logic**: Review the logic in your sign-in form submission handler to ensure it's adequately handling successful sign-ins. Since commenting out the redirect doesn’t resolve the hanging state, you may want to look into session state or await the redirect logic properly. 5. **Consult Documentation**: Review the documentation for the new OAuth Provider, as there may be specific requirements or known issues pertaining to your version [[2]](https://github.com/better-auth/better-auth/pull/4163). If you continue to experience issues after these steps, please tag @bekacru and @ping__ for further assistance from the Better Auth team. What other integrations or configurations are you considering with Better Auth? _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

@dosubot[bot] commented on GitHub (Jan 22, 2026):

The root cause is that after sign-in, the OAuth provider plugin returns a JSON response with a redirect URL, but your frontend isn't handling this response to continue the OAuth flow. The default sign-in handler (with redirect('/')) only does a local navigation and doesn't trigger the redirect back to the consumer app, so the OAuth flow gets stuck source.

To fix this, update your sign-in handler to check if the response includes a redirect URL and, if so, set window.location.href to that URL. For example:

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget as HTMLFormElement);
    const email = formData.get("email") as string;
    const password = formData.get("password") as string;

    setLoading(true);
    setError(null);
    const { data, error } = await authClient.signIn.email({
        email,
        password,
        fetchOptions: {
            onSuccess: async (context) => {
                // If the response is a redirect, follow it
                if (context.data?.redirect && context.data?.url) {
                    window.location.href = context.data.url;
                }
            }
        }
    });
    if (error) {
        setError(error.message ?? "Something went wrong");
        setLoading(false);
        return;
    }
    // No need for a local redirect here
}

This ensures that after a successful sign-in, your app will follow the redirect URL provided by the OAuth provider, completing the flow and returning the user to the consumer app source.

If you have custom consent or continue flows, make sure they also handle the JSON { redirect: true, url } response in the same way. Be aware of CORS and cookie settings if your apps are on different domains source.

Let me know if you need a Next.js-specific example or run into any CORS/cookie issues.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

@dosubot[bot] commented on GitHub (Jan 22, 2026): <!-- Answer --> The root cause is that after sign-in, the OAuth provider plugin returns a JSON response with a redirect URL, but your frontend isn't handling this response to continue the OAuth flow. The default sign-in handler (with `redirect('/')`) only does a local navigation and doesn't trigger the redirect back to the consumer app, so the OAuth flow gets stuck [source](https://github.com/better-auth/better-auth/issues/1160). To fix this, update your sign-in handler to check if the response includes a redirect URL and, if so, set `window.location.href` to that URL. For example: ```ts const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget as HTMLFormElement); const email = formData.get("email") as string; const password = formData.get("password") as string; setLoading(true); setError(null); const { data, error } = await authClient.signIn.email({ email, password, fetchOptions: { onSuccess: async (context) => { // If the response is a redirect, follow it if (context.data?.redirect && context.data?.url) { window.location.href = context.data.url; } } } }); if (error) { setError(error.message ?? "Something went wrong"); setLoading(false); return; } // No need for a local redirect here } ``` This ensures that after a successful sign-in, your app will follow the redirect URL provided by the OAuth provider, completing the flow and returning the user to the consumer app [source](https://github.com/better-auth/better-auth/issues/984). If you have custom consent or continue flows, make sure they also handle the JSON `{ redirect: true, url }` response in the same way. Be aware of CORS and cookie settings if your apps are on different domains [source](https://github.com/better-auth/better-auth/discussions/5336). Let me know if you need a Next.js-specific example or run into any CORS/cookie issues. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/c37651c2-1583-4185-a0d9-d618b3c9d59d?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/c37651c2-1583-4185-a0d9-d618b3c9d59d?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/c37651c2-1583-4185-a0d9-d618b3c9d59d?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/c37651c2-1583-4185-a0d9-d618b3c9d59d?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/c37651c2-1583-4185-a0d9-d618b3c9d59d?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/c37651c2-1583-4185-a0d9-d618b3c9d59d?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/c37651c2-1583-4185-a0d9-d618b3c9d59d?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=The%20root%20cause%20is%20that%20after%20sign-in%2C%20the%20OAuth%20provider%20plugin%20returns%20a%20JSON%20response%20with%20a%20redirect%20URL%2C%20but%20your%20frontend%20isn%27t%20handling%20this%20response%20to%20continue%20the%20OAuth%20flow.%20The%20default%20sign-in%20handler%20%28with%20%60redirect%28%27/%27%29%60%29%20only%20does%20a%20local%20navigation%20and%20doesn%27t%20trigger%20the%20redirect%20back%20to%20the%20consumer%20app%2C%20so%20the%20OAuth%20flow%20gets%20stuck%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/1160%29.%0A%0ATo%20fix%20this%2C%20update%20your%20sign-in%20handler%20to%20check%20if%20the%20response%20includes%20a%20redirect%20URL%20and%2C%20if%20so%2C%20set%20%60window.location.href%60%20to%20that%20URL.%20For%20example%3A%0A%0A%60%60%60ts%0Aconst%20handleSubmit%20%3D%20async%20%28e%3A%20React.FormEvent%3CHTMLFormElement%3E%29%20%3D%3E%20%7B%0A%20%20%20%20e.preventDefault%28%29%3B%0A%20%20%20%20const%20formData%20%3D%20new%20FormData%28e.currentTarget%20as%20HTMLFormElement%29%3B%0A%20%20%20%20const%20email%20%3D%20formData.get%28%22email%22%29%20as%20string%3B%0A%20%20%20%20const%20password%20%3D%20formData.get%28%22password%22%29%20as%20string%3B%0A%0A%20%20%20%20setLoading%28true%29%3B%0A%20%20%20%20setError%28null%29%3B%0A%20%20%20%20const%20%7B%20data%2C%20error%20%7D%20%3D%20await%20authClient.signIn.email%28%7B%0A%20%20%20%20%20%20%20%20email%2C%0A%20%20%20%20%20%20%20%20password%2C%0A%20%20%20%20%20%20%20%20fetchOptions%3A%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20onSuccess%3A%20async%20%28context%29%20%3D%3E%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20//%20If%20the%20response%20is%20a%20redirect%2C%20follow%20it%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20if%20%28context.data%3F.redirect%20%26%26%20context.data%3F.url%29%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20window.location.href%20%3D%20context.data.url%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%29%3B%0A%20%20%20%20if%20%28error%29%20%7B%0A%20%20%20%20%20%20%20%20setError%28error.message%20%3F%3F%20%22Something%20went%20wrong%22%29%3B%0A%20%20%20%20%20%20%20%20setLoading%28false%29%3B%0A%20%20%20%20%20%20%20%20return%3B%0A%20%20%20%20%7D%0A%20%20%20%20//%20No%20need%20for%20a%20local%20redirect%20here%0A%7D%0A%60%60%60%0A%0AThis%20ensures%20that%20after%20a%20successful%20sign-in%2C%20your%20app%20will%20follow%20the%20redirect%20URL%20provided%20by%20the%20OAuth%20provider%2C%20completing%20the%20flow%20and%20returning%20the%20user%20to%20the%20consumer%20app%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/984%29.%0A%0AIf%20you%20have%20custom%20consent%20or%20continue%20flows%2C%20make%20sure%20they%20also%20handle%20the%20JSON%20%60%7B%20redirect%3A%20true%2C%20url%20%7D%60%20response%20in%20the%20same%20way.%20Be%20aware%20of%20CORS%20and%20cookie%20settings%20if%20your%20apps%20are%20on%20different%20domains%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/discussions/5336%29.%0A%0ALet%20me%20know%20if%20you%20need%20a%20Next.js-specific%20example%20or%20run%20into%20any%20CORS/cookie%20issues.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/7558)
Author
Owner

@dvanmali commented on GitHub (Jan 23, 2026):

Can confirm issue doesn't exist in v1.4.16

@dvanmali commented on GitHub (Jan 23, 2026): Can confirm issue doesn't exist in v1.4.16
Author
Owner

@pxtrickb commented on GitHub (Jan 23, 2026):

After implementing Dosubot's solution and fidgeting with the configs a bit more, I was able to get to a new state of error.

In the sign in flow, when I call authClient.signIn.email, I get a CORS error in the browser console because better-auth tries to fetch the OAuth client's callback URL instead of redirecting to it.

Here is my new signin page:

"use client";
import { authClient } from '@/lib/auth-client';
import { redirect, useSearchParams } from 'next/navigation';
 
export default function SignInPage() {
    return (
        <main className='flex flex-col gap-6 items-center justify-center h-screen'>
            <h3 className='text-2xl font-bold'>Sign in</h3>
            <div className='flex flex-col gap-2'>
                <form className='flex flex-col gap-2'
                    onSubmit={async (e) => {
                        e.preventDefault();
 
                        const formData = new FormData(e.currentTarget as HTMLFormElement);
                        const email = formData.get('email') as string;
                        const password = formData.get('password') as string;
 
                        const res = await authClient.signIn.email({
                            email,
                            password,
                            callbackURL: '/',
                            fetchOptions: {
                                onSuccess: (ctx) => {
                                    if (ctx.data?.redirect && ctx.data?.url) {
                                        window.location.href = ctx.data.url;
                                    }
                                },
                                onError: (error) => {
                                    console.error(error);
                                },
                            },
                        });
                    }}
                >
                    <input name='email' type='email' placeholder='Email' required />
                    <input name='password' type='password' placeholder='Password' required />
                    <button type='submit'>Sign in</button>
                </form>
            </div>
        </main>
    )
}

And the error I get:

Access to fetch at 'https://[OAuth Client URL]/api/auth/oauth2/callback/[provider-id]?code=jvP1PMidwLEQ7480CV1TdbpGqAlxo0tM&state=ztEd133q8cLtuUOyAFdb6BVPI1XpWmFf' (redirected from 'https://[OAuth Provider URL]/api/auth/sign-in/email') from origin '[OAuth Provider URL]' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

The OAuth Provider plugin config:

oauthProvider({
  disableJwtPlugin: true,
  loginPage: "/signin",
  consentPage: "/consent",
})

The Generic OAuth plugin config (on the client app):

genericOAuth({
config: [
  {
    providerId: "[Provider ID]",
    clientId: process.env.[Client ID]!,
    clientSecret: process.env.[Client Secret]!,
    authorizationUrl: `${process.env.[OAuth Provider URL]}/api/auth/oauth2/authorize`,
    tokenUrl: `${process.env.[OAuth Provider URL]}/api/auth/oauth2/token`,
    userInfoUrl: `${process.env.[OAuth Provider URL]}/api/auth/oauth2/userinfo`,
    pkce: true
  }
]
})
@pxtrickb commented on GitHub (Jan 23, 2026): After implementing Dosubot's solution and fidgeting with the configs a bit more, I was able to get to a new state of error. In the sign in flow, when I call `authClient.signIn.email`, I get a CORS error in the browser console because better-auth tries to fetch the OAuth client's callback URL instead of redirecting to it. Here is my new signin page: ```ts "use client"; import { authClient } from '@/lib/auth-client'; import { redirect, useSearchParams } from 'next/navigation'; export default function SignInPage() { return ( <main className='flex flex-col gap-6 items-center justify-center h-screen'> <h3 className='text-2xl font-bold'>Sign in</h3> <div className='flex flex-col gap-2'> <form className='flex flex-col gap-2' onSubmit={async (e) => { e.preventDefault(); const formData = new FormData(e.currentTarget as HTMLFormElement); const email = formData.get('email') as string; const password = formData.get('password') as string; const res = await authClient.signIn.email({ email, password, callbackURL: '/', fetchOptions: { onSuccess: (ctx) => { if (ctx.data?.redirect && ctx.data?.url) { window.location.href = ctx.data.url; } }, onError: (error) => { console.error(error); }, }, }); }} > <input name='email' type='email' placeholder='Email' required /> <input name='password' type='password' placeholder='Password' required /> <button type='submit'>Sign in</button> </form> </div> </main> ) } ``` And the error I get: ``` Access to fetch at 'https://[OAuth Client URL]/api/auth/oauth2/callback/[provider-id]?code=jvP1PMidwLEQ7480CV1TdbpGqAlxo0tM&state=ztEd133q8cLtuUOyAFdb6BVPI1XpWmFf' (redirected from 'https://[OAuth Provider URL]/api/auth/sign-in/email') from origin '[OAuth Provider URL]' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. ``` The OAuth Provider plugin config: ```ts oauthProvider({ disableJwtPlugin: true, loginPage: "/signin", consentPage: "/consent", }) ``` The Generic OAuth plugin config (on the client app): ```ts genericOAuth({ config: [ { providerId: "[Provider ID]", clientId: process.env.[Client ID]!, clientSecret: process.env.[Client Secret]!, authorizationUrl: `${process.env.[OAuth Provider URL]}/api/auth/oauth2/authorize`, tokenUrl: `${process.env.[OAuth Provider URL]}/api/auth/oauth2/token`, userInfoUrl: `${process.env.[OAuth Provider URL]}/api/auth/oauth2/userinfo`, pkce: true } ] }) ```
Author
Owner

@dvanmali commented on GitHub (Jan 23, 2026):

@pxtrickb The CORS error is coming from the callback not the OAuth Provider. The callback is handled typically by a server, not the frontend. Im assuming the frontend is calling the token endpoint? Typically this is performed server side unless your testing flows from the browser. My example adds CORS to test in development but typically this isn't used in production.

@dvanmali commented on GitHub (Jan 23, 2026): @pxtrickb The CORS error is coming from the callback not the OAuth Provider. The callback is handled typically by a server, not the frontend. Im assuming the frontend is calling the token endpoint? Typically this is performed server side unless your testing flows from the browser. My [example](https://github.com/better-auth/better-auth/blob/canary/demo/nextjs/app/api/auth/%5B...all%5D/route.ts) adds CORS to test in development but typically this isn't used in production.
Author
Owner

@micaste commented on GitHub (Jan 29, 2026):

@pxtrickb I am having the same issue as you on v1.4.12 and v1.4.18. I am trying to implement OAuth with OpenAI and when the user enters the login flow and has already given their consent, the frontend hook signin.email will try to fetch the chatgpt page instead of returning the redirect as structured data. It then just gives the error "Failed to fetch".

What's a shame as well is that the example https://github.com/better-auth/better-auth/tree/canary/demo/nextjs has the mcp example implementation (with /api/mcp/ routes) which is a great start, but when you go through the oauth flow, it doesn't redirect you back to the client (OpenAI in my case), and therefore doesn't show the issues.

For now I implemented custom server actions for handling the flow. It looks like this:

Click to Expand
"use server"

import { cookies, headers } from "next/headers"
import { auth } from "@/server/auth/auth"
import { APIError } from "better-auth/api"

export type AuthResult =
  | { success: true; redirectUrl: string }
  | { success: false; error: string; code?: string }

type SignInInput = {
  email: string
  password: string
  captchaToken: string
  fullOrigin: string
}

async function setSessionCookies(responseHeaders: Headers) {
  const setCookieHeader = responseHeaders.get("set-cookie")
  if (setCookieHeader) {
    const cookieStore = await cookies()
    // Parse and set each cookie
    const cookieStrings = setCookieHeader.split(/,(?=\s*\w+=)/)
    for (const cookieString of cookieStrings) {
      const [nameValue, ...attributes] = cookieString.split(";")
      const [name, value] = nameValue.split("=")
      if (name && value) {
        const cookieOptions: {
          path?: string
          httpOnly?: boolean
          secure?: boolean
          sameSite?: "lax" | "strict" | "none"
          maxAge?: number
        } = {}

        for (const attr of attributes) {
          const [key, val] = attr.trim().split("=")
          const keyLower = key?.toLowerCase()
          if (keyLower === "path") cookieOptions.path = val
          if (keyLower === "httponly") cookieOptions.httpOnly = true
          if (keyLower === "secure") cookieOptions.secure = true
          if (keyLower === "samesite")
            cookieOptions.sameSite = val?.toLowerCase() as
              | "lax"
              | "strict"
              | "none"
          if (keyLower === "max-age") cookieOptions.maxAge = parseInt(val, 10)
        }

        cookieStore.set(name.trim(), decodeURIComponent(value), cookieOptions)
      }
    }
  }
}

export async function signInWithEmail(input: SignInInput): Promise<AuthResult> {
  try {
    const headersList = await headers()

    // Create a new Headers object with the captcha token
    const requestHeaders = new Headers(headersList)
    requestHeaders.set("x-captcha-response", input.captchaToken)

    const { headers: responseHeaders } = await auth.api.signInEmail({
      body: {
        email: input.email,
        password: input.password,
      },
      headers: requestHeaders,
      returnHeaders: true,
    })

    await setSessionCookies(responseHeaders)

    const { searchParams, search } = new URL(input.fullOrigin)
    const clientId = searchParams.get("client_id")
    const redirectUri = searchParams.get("redirect_uri")
    const isOAuthFlow = !!clientId && !!redirectUri
    // If in OAuth flow, continue the OAuth authorization
    if (isOAuthFlow) {
      const authHeaders = new Headers()
      authHeaders.set("Cookie", responseHeaders.get("set-cookie") ?? "")
      const oauthConsents = await auth.api.getOAuthConsents({
        headers: authHeaders,
      })
      const needsConsent = !oauthConsents.some((consent) => {
        return consent.clientId === clientId
      })
      return {
        success: true,
        redirectUrl: needsConsent ? "oauth/consent" + search : redirectUri,
      }
    }

    return { success: true, redirectUrl: "/app" }
  } catch (error) {
    console.error("Sign in error:", error)

    if (error instanceof APIError) {
      return {
        success: false,
        error: error.message || "An error occurred during login",
        code: error.body?.code as string | undefined,
      }
    }

    const message =
      error instanceof Error
        ? error.message
        : "An unknown error occurred during login"
    return { success: false, error: message }
  }
}
@micaste commented on GitHub (Jan 29, 2026): @pxtrickb I am having the same issue as you on v1.4.12 and v1.4.18. I am trying to implement OAuth with OpenAI and when the user enters the login flow and has already given their consent, the frontend hook `signin.email` will try to fetch the chatgpt page instead of returning the redirect as structured data. It then just gives the error "Failed to fetch". What's a shame as well is that the example https://github.com/better-auth/better-auth/tree/canary/demo/nextjs has the mcp example implementation (with `/api/mcp/` routes) which is a great start, but when you go through the oauth flow, it doesn't redirect you back to the client (OpenAI in my case), and therefore doesn't show the issues. For now I implemented custom server actions for handling the flow. It looks like this: <details> <summary>Click to Expand</summary> ```typescript "use server" import { cookies, headers } from "next/headers" import { auth } from "@/server/auth/auth" import { APIError } from "better-auth/api" export type AuthResult = | { success: true; redirectUrl: string } | { success: false; error: string; code?: string } type SignInInput = { email: string password: string captchaToken: string fullOrigin: string } async function setSessionCookies(responseHeaders: Headers) { const setCookieHeader = responseHeaders.get("set-cookie") if (setCookieHeader) { const cookieStore = await cookies() // Parse and set each cookie const cookieStrings = setCookieHeader.split(/,(?=\s*\w+=)/) for (const cookieString of cookieStrings) { const [nameValue, ...attributes] = cookieString.split(";") const [name, value] = nameValue.split("=") if (name && value) { const cookieOptions: { path?: string httpOnly?: boolean secure?: boolean sameSite?: "lax" | "strict" | "none" maxAge?: number } = {} for (const attr of attributes) { const [key, val] = attr.trim().split("=") const keyLower = key?.toLowerCase() if (keyLower === "path") cookieOptions.path = val if (keyLower === "httponly") cookieOptions.httpOnly = true if (keyLower === "secure") cookieOptions.secure = true if (keyLower === "samesite") cookieOptions.sameSite = val?.toLowerCase() as | "lax" | "strict" | "none" if (keyLower === "max-age") cookieOptions.maxAge = parseInt(val, 10) } cookieStore.set(name.trim(), decodeURIComponent(value), cookieOptions) } } } } export async function signInWithEmail(input: SignInInput): Promise<AuthResult> { try { const headersList = await headers() // Create a new Headers object with the captcha token const requestHeaders = new Headers(headersList) requestHeaders.set("x-captcha-response", input.captchaToken) const { headers: responseHeaders } = await auth.api.signInEmail({ body: { email: input.email, password: input.password, }, headers: requestHeaders, returnHeaders: true, }) await setSessionCookies(responseHeaders) const { searchParams, search } = new URL(input.fullOrigin) const clientId = searchParams.get("client_id") const redirectUri = searchParams.get("redirect_uri") const isOAuthFlow = !!clientId && !!redirectUri // If in OAuth flow, continue the OAuth authorization if (isOAuthFlow) { const authHeaders = new Headers() authHeaders.set("Cookie", responseHeaders.get("set-cookie") ?? "") const oauthConsents = await auth.api.getOAuthConsents({ headers: authHeaders, }) const needsConsent = !oauthConsents.some((consent) => { return consent.clientId === clientId }) return { success: true, redirectUrl: needsConsent ? "oauth/consent" + search : redirectUri, } } return { success: true, redirectUrl: "/app" } } catch (error) { console.error("Sign in error:", error) if (error instanceof APIError) { return { success: false, error: error.message || "An error occurred during login", code: error.body?.code as string | undefined, } } const message = error instanceof Error ? error.message : "An unknown error occurred during login" return { success: false, error: message } } } ``` </summary>
Author
Owner

@micaste commented on GitHub (Jan 30, 2026):

@pxtrickb The CORS error is coming from the callback not the OAuth Provider. The callback is handled typically by a server, not the frontend. Im assuming the frontend is calling the token endpoint? Typically this is performed server side unless your testing flows from the browser. My example adds CORS to test in development but typically this isn't used in production.

@dvanmali with the example that @pxtrickb shared, await authClient.signIn.email({ is what does a fetch request to the oauth cb url, resulting in the CORS error. In my code base, this results in an error and doesn't even log the user in. I had to work around this by moving the user login logic to my own backend.

This is directly reproducible if the user has already given their consent (or maybe if the option skipConsent is used?). If consent is required, something similar is happening where await authClient.signIn.email({ will fetch the consent page instead of returning the proper json content.

I think that the failure must come from theoauthProviderClient adding hooks to the email signin, and not handling properly the redirects from the backend

@micaste commented on GitHub (Jan 30, 2026): > [@pxtrickb](https://github.com/pxtrickb) The CORS error is coming from the callback not the OAuth Provider. The callback is handled typically by a server, not the frontend. Im assuming the frontend is calling the token endpoint? Typically this is performed server side unless your testing flows from the browser. My [example](https://github.com/better-auth/better-auth/blob/canary/demo/nextjs/app/api/auth/%5B...all%5D/route.ts) adds CORS to test in development but typically this isn't used in production. @dvanmali with the example that @pxtrickb shared, `await authClient.signIn.email({` is what does a fetch request to the oauth cb url, resulting in the CORS error. In my code base, this results in an error and doesn't even log the user in. I had to work around this by moving the user login logic to my own backend. This is directly reproducible if the user has already given their consent (or maybe if the option `skipConsent` is used?). If consent is required, something similar is happening where `await authClient.signIn.email({` will fetch the consent page instead of returning the proper json content. I think that the failure must come from the`oauthProviderClient` adding hooks to the email signin, and not handling properly the redirects from the backend
Author
Owner

@thihxm commented on GitHub (Jan 30, 2026):

That's probably it, I'm having the same issue with a skipConsent client. The plugin is fetching instead of redirecting in the last step of the sign-in flow.

It doesn't matter if it's a redirect to an error page or to the success page

@thihxm commented on GitHub (Jan 30, 2026): That's probably it, I'm having the same issue with a `skipConsent` client. The plugin is fetching instead of redirecting in the last step of the sign-in flow. It doesn't matter if it's a redirect to an error page or to the success page
Author
Owner

@samuelnh commented on GitHub (Feb 17, 2026):

@dvanmali is there a reason you closed your PR? This issue means I can't progress with OAuth as Better-Auth still just fetches the next page instead of redirecting.

@samuelnh commented on GitHub (Feb 17, 2026): @dvanmali is there a reason you closed your PR? This issue means I can't progress with OAuth as Better-Auth still just fetches the next page instead of redirecting.
Author
Owner

@dvanmali commented on GitHub (Feb 17, 2026):

@samuelnh There are two other PRs that should fix the redirection issue mentioned in this issue report: #7960 and #7811. (#7811 likely would close this, #7960 is a helper)

@dvanmali commented on GitHub (Feb 17, 2026): @samuelnh There are two other PRs that should fix the redirection issue mentioned in this issue report: #7960 and #7811. (#7811 likely would close this, #7960 is a helper)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2755