Proposal: Stateless OAuth State Management for Cross-Environment Authentication #850

Closed
opened 2026-03-13 08:07:07 -05:00 by GiteaMirror · 9 comments
Owner

Originally created by @rena0157 on GitHub (Mar 14, 2025).

Is this suited for github?

  • Yes, this is suited for github

When using the OAuth proxy plugin with different environments (e.g., development server proxying to production), authentication fails because:

  • The OAuth flow starts in one environment (development), where generateState() stores state data in the development database
  • After OAuth provider authentication, the callback is processed in another environment (production), where parseState() attempts to retrieve the state from the production database
  • The verification fails because the state exists in a different database

This creates a consistent authentication failure when testing across environments with separate databases.

Describe the solution you'd like

Implement a stateless state management approach that doesn't rely on database storage:

  • Encrypt all OAuth state data using the existing symmetricEncrypt utility
  • Pass the encrypted data directly in the state parameter
  • On callback, decrypt and validate the state parameter instead of querying the database

Describe alternatives you've considered

  • Not using preview databases
  • Implementing a Vercel Integration that dynamically adds redirect URIs

Additional context

Based on my limited understanding of how the package works I think this change could be made by simply updating the generateState and parseState functions

Here is a quick mock made by AI

// Modified generateState function
export async function generateState(c: GenericEndpointContext, link?: { email: string; userId: string; }) {
  const callbackURL = c.body?.callbackURL || c.context.options.baseURL;
  if (!callbackURL) {
    throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
  }
  
  const codeVerifier = generateRandomString(128);
  const stateData = {
    callbackURL,
    codeVerifier,
    errorURL: c.body?.errorCallbackURL,
    newUserURL: c.body?.newUserCallbackURL,
    link,
    expiresAt: Date.now() + 10 * 60 * 1000,
    requestSignUp: c.body?.requestSignUp
  };
  
  // Encrypt the state data instead of storing in DB
  const encryptedState = await symmetricEncrypt({
    key: c.context.secret,
    data: JSON.stringify(stateData)
  });
  
  return {
    state: encodeURIComponent(encryptedState),
    codeVerifier
  };
}

// Modified parseState function
export async function parseState(c: GenericEndpointContext) {
  const encryptedState = decodeURIComponent(c.query.state || c.body.state);
  try {
    const decryptedState = await symmetricDecrypt({
      key: c.context.secret,
      data: encryptedState
    });
    
    const parsedData = z.object({
      callbackURL: z.string(),
      codeVerifier: z.string(),
      errorURL: z.string().optional(),
      newUserURL: z.string().optional(),
      expiresAt: z.number(),
      link: z.object({
        email: z.string(),
        userId: z.string()
      }).optional(),
      requestSignUp: z.boolean().optional()
    }).parse(JSON.parse(decryptedState));
    
    if (!parsedData.errorURL) {
      parsedData.errorURL = `${c.context.baseURL}/error`;
    }
    
    if (parsedData.expiresAt < Date.now()) {
      throw new Error("State expired");
    }
    
    return parsedData;
  } catch (error) {
    c.context.logger.error("Failed to parse state", error);
    throw c.redirect(`${c.context.baseURL}/error?error=please_restart_the_process`);
  }
}
Originally created by @rena0157 on GitHub (Mar 14, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. When using the OAuth proxy plugin with different environments (e.g., development server proxying to production), authentication fails because: - The OAuth flow starts in one environment (development), where generateState() stores state data in the development database - After OAuth provider authentication, the callback is processed in another environment (production), where parseState() attempts to retrieve the state from the production database - The verification fails because the state exists in a different database This creates a consistent authentication failure when testing across environments with separate databases. ### Describe the solution you'd like Implement a stateless state management approach that doesn't rely on database storage: - Encrypt all OAuth state data using the existing symmetricEncrypt utility - Pass the encrypted data directly in the state parameter - On callback, decrypt and validate the state parameter instead of querying the database ### Describe alternatives you've considered - Not using preview databases - Implementing a Vercel Integration that dynamically adds redirect URIs ### Additional context Based on my limited understanding of how the package works I think this change could be made by simply updating the `generateState` and `parseState` functions Here is a quick mock made by AI ```ts // Modified generateState function export async function generateState(c: GenericEndpointContext, link?: { email: string; userId: string; }) { const callbackURL = c.body?.callbackURL || c.context.options.baseURL; if (!callbackURL) { throw new APIError("BAD_REQUEST", { message: "callbackURL is required" }); } const codeVerifier = generateRandomString(128); const stateData = { callbackURL, codeVerifier, errorURL: c.body?.errorCallbackURL, newUserURL: c.body?.newUserCallbackURL, link, expiresAt: Date.now() + 10 * 60 * 1000, requestSignUp: c.body?.requestSignUp }; // Encrypt the state data instead of storing in DB const encryptedState = await symmetricEncrypt({ key: c.context.secret, data: JSON.stringify(stateData) }); return { state: encodeURIComponent(encryptedState), codeVerifier }; } // Modified parseState function export async function parseState(c: GenericEndpointContext) { const encryptedState = decodeURIComponent(c.query.state || c.body.state); try { const decryptedState = await symmetricDecrypt({ key: c.context.secret, data: encryptedState }); const parsedData = z.object({ callbackURL: z.string(), codeVerifier: z.string(), errorURL: z.string().optional(), newUserURL: z.string().optional(), expiresAt: z.number(), link: z.object({ email: z.string(), userId: z.string() }).optional(), requestSignUp: z.boolean().optional() }).parse(JSON.parse(decryptedState)); if (!parsedData.errorURL) { parsedData.errorURL = `${c.context.baseURL}/error`; } if (parsedData.expiresAt < Date.now()) { throw new Error("State expired"); } return parsedData; } catch (error) { c.context.logger.error("Failed to parse state", error); throw c.redirect(`${c.context.baseURL}/error?error=please_restart_the_process`); } } ```
Author
Owner

@jetwatki commented on GitHub (Apr 1, 2025):

Hey this would be so helpful!!

@jetwatki commented on GitHub (Apr 1, 2025): Hey this would be so helpful!!
Author
Owner

@shaug commented on GitHub (Jun 23, 2025):

I submitted a PR to address this issue. Rather than switch to stateless, I allowed for defining generateState/parseState OAuth callbacks in the betterAuth config. This allows an installation to rely on the default implementation (persisting to the verification table) if they don't require anything different, but if custom behavior is needed, the service can tailor how the state is generated and parsed according to the requirements of their product.

While I think the AI-derived solution is fine, it might have some drawbacks:

  • symmetric encryption means that all services need to share the same secret used to encrypt/decrypt the data, which may not be an acceptable security posture
  • symmetericEncrypt uses hex encoding to represent the binary data, which isn't as efficient as base64, and some OAuth providers may not support overly sized state providers
  • Likewise, the codeVerifier consumes 128 bytes of the payload, and it's not necessarily required that it be encoded in the payload, just that it's cryptographically secure and consistent between generation and parsing of the same state value.

At any rate, I'm not sure there's a one-size-fits-all solution for "stateless" state encoding. I think the PR I submitted allows for a solution to the problem of OAuth proxies that don't share the same datastore, without dictating an approach that is not compatible with their other requirements.

@shaug commented on GitHub (Jun 23, 2025): I submitted [a PR to address this issue](https://github.com/better-auth/better-auth/pull/3125). Rather than switch to stateless, I allowed for defining `generateState`/`parseState` OAuth callbacks in the betterAuth config. This allows an installation to rely on the default implementation (persisting to the `verification` table) if they don't require anything different, but if custom behavior is needed, the service can tailor how the state is generated and parsed according to the requirements of their product. While I think the AI-derived solution is fine, it might have some drawbacks: - symmetric encryption means that all services need to share the same secret used to encrypt/decrypt the data, which may not be an acceptable security posture - `symmetericEncrypt` uses hex encoding to represent the binary data, which isn't as efficient as base64, and some OAuth providers may not support overly sized `state` providers - Likewise, the `codeVerifier` consumes 128 bytes of the payload, and it's not necessarily required that it be encoded in the payload, just that it's cryptographically secure and consistent between generation and parsing of the same state value. At any rate, I'm not sure there's a one-size-fits-all solution for "stateless" `state` encoding. I think the PR I submitted allows for a solution to the problem of OAuth proxies that don't share the same datastore, without dictating an approach that is not compatible with their other requirements.
Author
Owner

@shaug commented on GitHub (Jun 25, 2025):

Actually, I don't think my proposed PR is an adequate solution to this problem when used with the library's oAuthProxy plugin, mostly because the oAuthProxy plugin still requires that the originating service and the proxy service share the same BetterAuth secret config, which is not good security practice, in my opinion.

In my opinion, the library's proxy plugin could be vastly improved. Below I've included the one we're now using for our project. It is simpler than the 'standard' oAuthProxy plugin, as it doesn't create new endpoints -- it only intercepts the existing OAuth endpoints. It also doesn't require sharing secrets (beyond the provider's client's secret, which is necessary due to the provider's requirements, not BetterAuth's), and doesn't require sharing storage. It intercepts the outbound POST request to the provider and replaces the state parameter with an encrypted payload that includes the original state and the baseURL, and it intercepts the redirect on the proxy server to decrypt that state to rewrite the redirect back to the originating server.

It currently only works with the existing socialProviders config, but with some minor updates it could also support the 'generic oauth' plugin.

I may submit this as a proper PR to the project, but it might be easier just to build this into a third-party plugin library. At any rate, you're welcome to the code.

/**
 * OAuth Proxy Plugin for Better Auth
 *
 * This plugin enables OAuth flows to work across multiple environments (dev,
 * staging, production) without requiring shared databases or additional shared
 * secrets. It acts as a "smart router" that intercepts OAuth requests and
 * responses to transparently route them between originating servers and OAuth
 * providers.
 *
 * ## How It Works
 *
 * ### Stage 1: Request Interception (Originating Server)
 *
 * When an OAuth sign-in flow is initiated on any server (dev, staging, etc.):
 * 1. Better Auth generates a standard persisted state parameter
 * 2. Better Auth creates the OAuth sign-in request with the state parameter
 * 3. The plugin's `after` hook intercepts the OAuth sign-in response
 * 4. Plugin extracts the `redirectURI` from the OAuth request URL
 * 5. If the `redirectURI` matches the configured app's baseURL, the plugin does
 *    nothing and lets the service act normally, as it's not a request that
 *    requires proxying.
 * 6. Plugin matches the `redirectURI` against configured social provider
 *    redirect URIs
 * 7. Plugin gets the corresponding provider's client secret
 * 8. Plugin creates a JSON payload containing original state ID and originating
 *    server's baseURL
 * 9. Plugin symmetrically encrypts the JSON payload using the provider's client
 *    secret, as this secret already needs to be shared with the proxy server.
 * 10. Plugin replaces the state parameter with `_proxy_` + encrypted payload
 * 11. OAuth request proceeds to the provider with the encrypted state
 *
 * ### Stage 2: Callback Interception (Proxy Server)
 *
 * When the OAuth provider redirects back to the proxy server:
 * 1. Plugin's `before` hook intercepts the callback request
 * 2. If the state parameter doesn't start with `_proxy_`, the plugin does
 *    nothing and lets normal OAuth processing continue
 * 3. Plugin extracts the provider from the callback path
 * 4. Plugin gets that provider's client secret from config
 * 5. Plugin symmetrically decrypts the state parameter using the provider's
 *    secret
 * 6. If valid, plugin extracts original state ID and originating server's
 *    baseURL
 * 7. Plugin reconstructs the redirect URL with the original state
 * 8. Plugin redirects to the originating server, bypassing normal OAuth
 *    processing
 *
 * ## Security Considerations
 *
 * - Payload is symmetrically encrypted using the OAuth provider's client secret
 * - Each provider uses its own secret for its flows
 * - No additional shared secrets required
 * - OAuth flow's own expiration mechanism handles timing
 */
import type { BetterAuthPlugin } from 'better-auth';
import { createAuthMiddleware } from 'better-auth/api';
import { symmetricDecrypt, symmetricEncrypt } from 'better-auth/crypto';

// The prefix to append to the state parameter to indicate it's a proxy state
// This allows the production service to identify state parameters for proxied
// services vs its own standard state parameters.
const STATE_PREFIX = '_proxy_';

/**
 * Interface for the encrypted OAuth proxy state payload.
 *
 * This information will be encoded as a state parameter when relying on the
 * proxy server to route the OAuth flow back to the originating server. The
 * proxy server will use this information to reconstruct the redirect URL and
 * redirect the user back to the originating server's OAuth redirect endpoint.
 */
interface OAuthProxyState {
  // The original persisted state ID
  state: string;
  // The originating server's callback URL
  baseURL: string;
}

/**
 * Interface for Better Auth redirect responses for middleware plugins.
 *
 * The payload received as part of an `after` hook can take many forms, but if
 * it matches this interface, it can be used to forward the OAuth flow to a
 * proxy server.
 */
interface BetterAuthResponse {
  // The OAuth provider's authentication URL.
  url: string;
}

/**
 * Interface for Better Auth context
 */
interface MiddlewareContext {
  context: {
    options: {
      socialProviders: Record<string, { clientSecret: string; redirectURI: string }>;
    };
  };
}

/**
 * Extracts the provider name from a callback URL path
 */
function extractProviderFromPath(path: string): string | null {
  // Iterate over the parts and return the first one that isn't one of the
  // following:
  // - api
  // - auth
  // - oauth2
  // - callback
  const ignoredParts = ['api', 'auth', 'oauth2', 'callback', ''];
  for (const part of path.split('/')) {
    if (!ignoredParts.includes(part)) {
      return part;
    }
  }
  return null;
}

/**
 * Finds a provider by matching its redirect URI
 */
function findProviderByRedirectUri(
  redirectURI: string,
  ctx: MiddlewareContext,
): { providerName: string; provider: { clientSecret: string; redirectURI: string } } | null {
  const socialProviders = ctx.context.options.socialProviders;

  for (const [name, config] of Object.entries(socialProviders)) {
    if (config.redirectURI === redirectURI) {
      return { providerName: name, provider: config };
    }
  }
  return null;
}

/**
 * Creates the OAuth Proxy Plugin for Better Auth
 */
export function oAuthProxy(): BetterAuthPlugin {
  return {
    id: 'oauth-proxy',
    hooks: {
      after: [
        {
          matcher(context) {
            return (
              context.path?.startsWith('/sign-in/social') ||
              context.path?.startsWith('/sign-in/oauth2')
            );
          },
          handler: createAuthMiddleware(async (ctx) => {
            const logger = ctx.context.logger;

            // Check for Better Auth response format
            const response = ctx.context.returned as BetterAuthResponse;
            if (!response?.url) {
              logger.warn('oauth-proxy: No Better Auth redirect response found');
              return;
            }

            // Parse the OAuth URL to extract parameters
            const oauthURL = new URL(response.url);

            const redirectURI = oauthURL.searchParams.get('redirect_uri');

            if (!redirectURI) {
              logger.warn('oauth-proxy: No redirect_uri found in OAuth request');
              return;
            }

            // Check if this is our own callback URL (let it proceed normally)
            const baseURL = ctx.context.options.baseURL || process.env.BETTER_AUTH_URL || '';
            if (redirectURI.startsWith(baseURL)) {
              logger.info('oauth-proxy: Redirect URI matches base URL, proceeding normally');
              return;
            }

            // Find the corresponding provider configuration by matching redirectURI
            const providerResult = findProviderByRedirectUri(redirectURI, ctx as MiddlewareContext);
            if (!providerResult) {
              logger.warn(
                `oauth-proxy: No matching provider found for redirectURI: ${redirectURI}`,
              );
              return;
            }

            const { providerName, provider } = providerResult;
            if (!provider.clientSecret) {
              logger.warn(`oauth-proxy[${providerName}]: Provider has no client secret`);
              return;
            }

            // Get the original state parameter
            const state = oauthURL.searchParams.get('state');
            if (!state) {
              logger.warn(`oauth-proxy[${providerName}]: No state parameter found in request`);
              return;
            }

            // Create the proxy state payload
            const proxyState: OAuthProxyState = { state, baseURL };

            // Encrypt the payload using the provider's client secret
            const encryptedPayload = await symmetricEncrypt({
              key: provider.clientSecret,
              data: JSON.stringify(proxyState),
            });

            // Replace the state parameter with the encrypted proxy state
            const newState = `${STATE_PREFIX}${encryptedPayload}`;
            oauthURL.searchParams.set('state', newState);

            // Update the original response URL
            response.url = oauthURL.toString();
            logger.info(`oauth-proxy[${providerName}]: ${JSON.stringify(proxyState)}`);
          }),
        },
      ],

      before: [
        {
          matcher(context) {
            return (
              context.path?.startsWith('/callback') || context.path?.startsWith('/oauth2/callback')
            );
          },
          handler: createAuthMiddleware(async (ctx) => {
            const logger = ctx.context.logger;

            // Get the state parameter from the request
            const requestUrl = ctx.request?.url;
            if (!requestUrl) {
              logger.info('oauth-proxy: No request URL found');
              return;
            }

            const url = new URL(requestUrl);
            const state = url.searchParams.get('state');
            if (!state || !state.startsWith(STATE_PREFIX)) {
              logger.info('oauth-proxy: No proxy state parameter found, proceeding normally');
              return;
            }

            // Extract the provider from the callback path
            const providerName = extractProviderFromPath(String(url.pathname || ''));
            if (!providerName) {
              logger.warn(
                `oauth-proxy: Could not determine provider from callback path ${url.pathname}`,
              );
              return;
            }

            // Get the provider configuration
            const socialProviders = ctx.context.options.socialProviders as Record<
              string,
              { clientSecret: string; redirectURI: string }
            >;
            const provider = socialProviders[providerName];

            if (!provider || !provider.clientSecret) {
              logger.warn(`oauth-proxy[${providerName}]: No provider configuration found`);
              return;
            }

            // Extract and decrypt the proxy state
            const encryptedPayload = state.substring(STATE_PREFIX.length);
            const decryptedData = await symmetricDecrypt({
              key: provider.clientSecret,
              data: encryptedPayload,
            });

            const proxyState: OAuthProxyState = JSON.parse(decryptedData);

            // Reconstruct the redirect URL with the original state
            const redirectURL = new URL(requestUrl);
            const targetURL = new URL(proxyState.baseURL);

            // Replace host and path with the target service
            redirectURL.host = targetURL.host;
            redirectURL.protocol = targetURL.protocol;

            // Set the original state parameter
            redirectURL.searchParams.set('state', proxyState.state);
            const redirectTo = redirectURL.toString();

            logger.info(`oauth-proxy[${providerName}]: Redirecting to: ${redirectTo}`);

            // Return a redirect response
            throw ctx.redirect(redirectTo);
          }),
        },
      ],
    },
  };
}
@shaug commented on GitHub (Jun 25, 2025): Actually, I don't think my proposed PR is an adequate solution to this problem when used with the library's oAuthProxy plugin, mostly because the oAuthProxy plugin still requires that the originating service and the proxy service share the same BetterAuth `secret` config, which is not good security practice, in my opinion. In my opinion, the library's proxy plugin could be vastly improved. Below I've included the one we're now using for our project. It is simpler than the 'standard' oAuthProxy plugin, as it doesn't create new endpoints -- it only intercepts the existing OAuth endpoints. It also doesn't require sharing secrets (beyond the provider's client's secret, which is necessary due to the provider's requirements, not BetterAuth's), and doesn't require sharing storage. It intercepts the outbound POST request to the provider and replaces the `state` parameter with an encrypted payload that includes the original `state` and the `baseURL`, and it intercepts the redirect on the proxy server to decrypt that state to rewrite the redirect back to the originating server. It currently only works with the existing `socialProviders` config, but with some minor updates it could also support the 'generic oauth' plugin. I may submit this as a proper PR to the project, but it might be easier just to build this into a third-party plugin library. At any rate, you're welcome to the code. ```typescript /** * OAuth Proxy Plugin for Better Auth * * This plugin enables OAuth flows to work across multiple environments (dev, * staging, production) without requiring shared databases or additional shared * secrets. It acts as a "smart router" that intercepts OAuth requests and * responses to transparently route them between originating servers and OAuth * providers. * * ## How It Works * * ### Stage 1: Request Interception (Originating Server) * * When an OAuth sign-in flow is initiated on any server (dev, staging, etc.): * 1. Better Auth generates a standard persisted state parameter * 2. Better Auth creates the OAuth sign-in request with the state parameter * 3. The plugin's `after` hook intercepts the OAuth sign-in response * 4. Plugin extracts the `redirectURI` from the OAuth request URL * 5. If the `redirectURI` matches the configured app's baseURL, the plugin does * nothing and lets the service act normally, as it's not a request that * requires proxying. * 6. Plugin matches the `redirectURI` against configured social provider * redirect URIs * 7. Plugin gets the corresponding provider's client secret * 8. Plugin creates a JSON payload containing original state ID and originating * server's baseURL * 9. Plugin symmetrically encrypts the JSON payload using the provider's client * secret, as this secret already needs to be shared with the proxy server. * 10. Plugin replaces the state parameter with `_proxy_` + encrypted payload * 11. OAuth request proceeds to the provider with the encrypted state * * ### Stage 2: Callback Interception (Proxy Server) * * When the OAuth provider redirects back to the proxy server: * 1. Plugin's `before` hook intercepts the callback request * 2. If the state parameter doesn't start with `_proxy_`, the plugin does * nothing and lets normal OAuth processing continue * 3. Plugin extracts the provider from the callback path * 4. Plugin gets that provider's client secret from config * 5. Plugin symmetrically decrypts the state parameter using the provider's * secret * 6. If valid, plugin extracts original state ID and originating server's * baseURL * 7. Plugin reconstructs the redirect URL with the original state * 8. Plugin redirects to the originating server, bypassing normal OAuth * processing * * ## Security Considerations * * - Payload is symmetrically encrypted using the OAuth provider's client secret * - Each provider uses its own secret for its flows * - No additional shared secrets required * - OAuth flow's own expiration mechanism handles timing */ import type { BetterAuthPlugin } from 'better-auth'; import { createAuthMiddleware } from 'better-auth/api'; import { symmetricDecrypt, symmetricEncrypt } from 'better-auth/crypto'; // The prefix to append to the state parameter to indicate it's a proxy state // This allows the production service to identify state parameters for proxied // services vs its own standard state parameters. const STATE_PREFIX = '_proxy_'; /** * Interface for the encrypted OAuth proxy state payload. * * This information will be encoded as a state parameter when relying on the * proxy server to route the OAuth flow back to the originating server. The * proxy server will use this information to reconstruct the redirect URL and * redirect the user back to the originating server's OAuth redirect endpoint. */ interface OAuthProxyState { // The original persisted state ID state: string; // The originating server's callback URL baseURL: string; } /** * Interface for Better Auth redirect responses for middleware plugins. * * The payload received as part of an `after` hook can take many forms, but if * it matches this interface, it can be used to forward the OAuth flow to a * proxy server. */ interface BetterAuthResponse { // The OAuth provider's authentication URL. url: string; } /** * Interface for Better Auth context */ interface MiddlewareContext { context: { options: { socialProviders: Record<string, { clientSecret: string; redirectURI: string }>; }; }; } /** * Extracts the provider name from a callback URL path */ function extractProviderFromPath(path: string): string | null { // Iterate over the parts and return the first one that isn't one of the // following: // - api // - auth // - oauth2 // - callback const ignoredParts = ['api', 'auth', 'oauth2', 'callback', '']; for (const part of path.split('/')) { if (!ignoredParts.includes(part)) { return part; } } return null; } /** * Finds a provider by matching its redirect URI */ function findProviderByRedirectUri( redirectURI: string, ctx: MiddlewareContext, ): { providerName: string; provider: { clientSecret: string; redirectURI: string } } | null { const socialProviders = ctx.context.options.socialProviders; for (const [name, config] of Object.entries(socialProviders)) { if (config.redirectURI === redirectURI) { return { providerName: name, provider: config }; } } return null; } /** * Creates the OAuth Proxy Plugin for Better Auth */ export function oAuthProxy(): BetterAuthPlugin { return { id: 'oauth-proxy', hooks: { after: [ { matcher(context) { return ( context.path?.startsWith('/sign-in/social') || context.path?.startsWith('/sign-in/oauth2') ); }, handler: createAuthMiddleware(async (ctx) => { const logger = ctx.context.logger; // Check for Better Auth response format const response = ctx.context.returned as BetterAuthResponse; if (!response?.url) { logger.warn('oauth-proxy: No Better Auth redirect response found'); return; } // Parse the OAuth URL to extract parameters const oauthURL = new URL(response.url); const redirectURI = oauthURL.searchParams.get('redirect_uri'); if (!redirectURI) { logger.warn('oauth-proxy: No redirect_uri found in OAuth request'); return; } // Check if this is our own callback URL (let it proceed normally) const baseURL = ctx.context.options.baseURL || process.env.BETTER_AUTH_URL || ''; if (redirectURI.startsWith(baseURL)) { logger.info('oauth-proxy: Redirect URI matches base URL, proceeding normally'); return; } // Find the corresponding provider configuration by matching redirectURI const providerResult = findProviderByRedirectUri(redirectURI, ctx as MiddlewareContext); if (!providerResult) { logger.warn( `oauth-proxy: No matching provider found for redirectURI: ${redirectURI}`, ); return; } const { providerName, provider } = providerResult; if (!provider.clientSecret) { logger.warn(`oauth-proxy[${providerName}]: Provider has no client secret`); return; } // Get the original state parameter const state = oauthURL.searchParams.get('state'); if (!state) { logger.warn(`oauth-proxy[${providerName}]: No state parameter found in request`); return; } // Create the proxy state payload const proxyState: OAuthProxyState = { state, baseURL }; // Encrypt the payload using the provider's client secret const encryptedPayload = await symmetricEncrypt({ key: provider.clientSecret, data: JSON.stringify(proxyState), }); // Replace the state parameter with the encrypted proxy state const newState = `${STATE_PREFIX}${encryptedPayload}`; oauthURL.searchParams.set('state', newState); // Update the original response URL response.url = oauthURL.toString(); logger.info(`oauth-proxy[${providerName}]: ${JSON.stringify(proxyState)}`); }), }, ], before: [ { matcher(context) { return ( context.path?.startsWith('/callback') || context.path?.startsWith('/oauth2/callback') ); }, handler: createAuthMiddleware(async (ctx) => { const logger = ctx.context.logger; // Get the state parameter from the request const requestUrl = ctx.request?.url; if (!requestUrl) { logger.info('oauth-proxy: No request URL found'); return; } const url = new URL(requestUrl); const state = url.searchParams.get('state'); if (!state || !state.startsWith(STATE_PREFIX)) { logger.info('oauth-proxy: No proxy state parameter found, proceeding normally'); return; } // Extract the provider from the callback path const providerName = extractProviderFromPath(String(url.pathname || '')); if (!providerName) { logger.warn( `oauth-proxy: Could not determine provider from callback path ${url.pathname}`, ); return; } // Get the provider configuration const socialProviders = ctx.context.options.socialProviders as Record< string, { clientSecret: string; redirectURI: string } >; const provider = socialProviders[providerName]; if (!provider || !provider.clientSecret) { logger.warn(`oauth-proxy[${providerName}]: No provider configuration found`); return; } // Extract and decrypt the proxy state const encryptedPayload = state.substring(STATE_PREFIX.length); const decryptedData = await symmetricDecrypt({ key: provider.clientSecret, data: encryptedPayload, }); const proxyState: OAuthProxyState = JSON.parse(decryptedData); // Reconstruct the redirect URL with the original state const redirectURL = new URL(requestUrl); const targetURL = new URL(proxyState.baseURL); // Replace host and path with the target service redirectURL.host = targetURL.host; redirectURL.protocol = targetURL.protocol; // Set the original state parameter redirectURL.searchParams.set('state', proxyState.state); const redirectTo = redirectURL.toString(); logger.info(`oauth-proxy[${providerName}]: Redirecting to: ${redirectTo}`); // Return a redirect response throw ctx.redirect(redirectTo); }), }, ], }, }; } ```
Author
Owner

@timreinkeaxios commented on GitHub (Jul 1, 2025):

Thanks @shaug - that's working like a charm for me. I just had to add:

redirectURL.port = targetURL.port

since I'm also using this to handle auth for dev on localhost, as well as preview environments.

@timreinkeaxios commented on GitHub (Jul 1, 2025): Thanks @shaug - that's working like a charm for me. I just had to add: ``` redirectURL.port = targetURL.port ``` since I'm also using this to handle auth for dev on localhost, as well as preview environments.
Author
Owner

@dosubot[bot] commented on GitHub (Sep 30, 2025):

Hi, @rena0157. I'm Dosu, and I'm helping the better-auth team manage their backlog and am marking this issue as stale.

Issue Summary:

  • You proposed a stateless OAuth state management approach by encrypting all OAuth state data and passing it in the state parameter to avoid database storage.
  • The maintainer, shaug, responded with a PR allowing custom generateState/parseState callbacks for flexibility but noted some drawbacks with symmetric encryption and shared secrets.
  • Shaug later shared a refined OAuth proxy plugin implementation that encrypts state with the provider's client secret and intercepts OAuth requests and responses.
  • This solution eliminates the need for shared storage or secrets beyond the provider's client secret.
  • User timreinkeaxios confirmed the solution works well, adding a minor fix for localhost port handling.

Next Steps:

  • Please confirm if this issue is still relevant to the latest version of better-auth; if so, you can keep the discussion open by commenting here.
  • Otherwise, I will automatically close this issue in 7 days.

Thank you for your understanding and contribution!

@dosubot[bot] commented on GitHub (Sep 30, 2025): Hi, @rena0157. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary:** - You proposed a stateless OAuth state management approach by encrypting all OAuth state data and passing it in the state parameter to avoid database storage. - The maintainer, shaug, responded with a PR allowing custom generateState/parseState callbacks for flexibility but noted some drawbacks with symmetric encryption and shared secrets. - Shaug later shared a refined OAuth proxy plugin implementation that encrypts state with the provider's client secret and intercepts OAuth requests and responses. - This solution eliminates the need for shared storage or secrets beyond the provider's client secret. - User timreinkeaxios confirmed the solution works well, adding a minor fix for localhost port handling. **Next Steps:** - Please confirm if this issue is still relevant to the latest version of better-auth; if so, you can keep the discussion open by commenting here. - Otherwise, I will automatically close this issue in 7 days. Thank you for your understanding and contribution!
Author
Owner

@arlyon commented on GitHub (Oct 2, 2025):

Relevant

@arlyon commented on GitHub (Oct 2, 2025): Relevant
Author
Owner

@michidk commented on GitHub (Oct 28, 2025):

Took me way too long to figure this out. For anybody else who stumbles here (because dosu sent them here):

The current proxy plugin only works if the local/preview environment is on the same domain as the production one, as it relies on cookies that are not shared between domains.
The implementation by Shaug implements this differently, not relying on cookies but directly encrypting the state using the client secret. This means it will work for the preview environment on different domains (like the Vercel ones). It will also work locally if you include the modification from timreinkeaxios.

I actually wanted to use it with the generic-oauth provider. Here is my version:

/**
 * OAuth Proxy Plugin for Better Auth
 *
 * @source https://github.com/better-auth/better-auth/issues/1819#issuecomment-3003038278
 * @customizations
 * - Simplified for single provider (Cognito) instead of generic multi-provider support
 * - Provider config passed directly as plugin options instead of runtime detection
 * - Added port preservation for localhost redirects
 *
 * This plugin enables OAuth flows to work across localhost, preview, and production
 * environments without requiring shared databases. It routes authentication through
 * production by encrypting state information using the OAuth provider's client secret.
 */
import type { BetterAuthPlugin } from "better-auth";
import { createAuthMiddleware } from "better-auth/api";
import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";

// Prefix to identify proxy state parameters
const STATE_PREFIX = "_proxy_";

/**
 * Encrypted state payload passed through OAuth provider
 */
interface OAuthProxyState {
	state: string; // Original state ID
	baseURL: string; // Originating server URL
}

/**
 * Better Auth OAuth redirect response
 */
interface BetterAuthResponse {
	url: string;
}

/**
 * Configuration for the OAuth proxy plugin
 */
interface OAuthProxyOptions {
	providerId: string;
	clientSecret: string;
	redirectURI: string;
}

/**
 * Creates the OAuth Proxy Plugin for Better Auth
 */
export function oAuthProxy(options: OAuthProxyOptions): BetterAuthPlugin {
	return {
		id: "oauth-proxy",
		hooks: {
			after: [
				{
					matcher(context) {
						return context.path?.startsWith("/sign-in/oauth2");
					},
					handler: createAuthMiddleware(async (ctx) => {
						const logger = ctx.context.logger;

						const response = ctx.context.returned as BetterAuthResponse;
						if (!response?.url) {
							logger.warn("oauth-proxy: No response URL found");
							return;
						}

						const oauthURL = new URL(response.url);
						const redirectURI = oauthURL.searchParams.get("redirect_uri");

						if (!redirectURI) {
							logger.warn("oauth-proxy: No redirect_uri in request");
							return;
						}

						// Don't proxy if redirecting to our own baseURL
						const baseURL =
							ctx.context.options.baseURL || process.env.BETTER_AUTH_URL || "";
						if (redirectURI.startsWith(baseURL)) {
							return;
						}

						// Verify redirectURI matches configured production URL
						if (redirectURI !== options.redirectURI) {
							logger.warn(
								`oauth-proxy: redirectURI mismatch - expected ${options.redirectURI}`,
							);
							return;
						}

						const state = oauthURL.searchParams.get("state");
						if (!state) {
							logger.warn(
								`oauth-proxy[${options.providerId}]: No state parameter`,
							);
							return;
						}

						// Encrypt state + baseURL with provider's client secret
						const proxyState: OAuthProxyState = { state, baseURL };
						const encryptedPayload = await symmetricEncrypt({
							key: options.clientSecret,
							data: JSON.stringify(proxyState),
						});

						// Replace state with encrypted proxy state
						oauthURL.searchParams.set(
							"state",
							`${STATE_PREFIX}${encryptedPayload}`,
						);
						response.url = oauthURL.toString();

						logger.info(
							`oauth-proxy[${options.providerId}]: Proxied state for ${baseURL}`,
						);
					}),
				},
			],

			before: [
				{
					matcher(context) {
						return (
							context.path?.startsWith("/callback") ||
							context.path?.startsWith("/oauth2/callback")
						);
					},
					handler: createAuthMiddleware(async (ctx) => {
						const logger = ctx.context.logger;

						const requestUrl = ctx.request?.url;
						if (!requestUrl) {
							return;
						}

						const url = new URL(requestUrl);
						const state = url.searchParams.get("state");

						// Only process if state has proxy prefix
						if (!state || !state.startsWith(STATE_PREFIX)) {
							return;
						}

						// Decrypt proxy state using provider's client secret
						const encryptedPayload = state.substring(STATE_PREFIX.length);
						const decryptedData = await symmetricDecrypt({
							key: options.clientSecret,
							data: encryptedPayload,
						});

						const proxyState: OAuthProxyState = JSON.parse(decryptedData);

						// Redirect to originating server with original state
						const redirectURL = new URL(requestUrl);
						const targetURL = new URL(proxyState.baseURL);

						redirectURL.host = targetURL.host;
						redirectURL.protocol = targetURL.protocol;
						redirectURL.port = targetURL.port;
						redirectURL.searchParams.set("state", proxyState.state);

						const redirectTo = redirectURL.toString();
						logger.info(
							`oauth-proxy[${options.providerId}]: Redirecting to ${redirectTo}`,
						);

						throw ctx.redirect(redirectTo);
					}),
				},
			],
		},
	};
}
@michidk commented on GitHub (Oct 28, 2025): Took me way too long to figure this out. For anybody else who stumbles here (because dosu sent them here): The current proxy plugin only works if the local/preview environment is on the same domain as the production one, as it relies on cookies that are not shared between domains. The implementation by Shaug implements this differently, not relying on cookies but directly encrypting the state using the client secret. This means it will work for the preview environment on different domains (like the Vercel ones). It will also work locally if you include the modification from timreinkeaxios. I actually wanted to use it with the generic-oauth provider. Here is my version: ```typescript /** * OAuth Proxy Plugin for Better Auth * * @source https://github.com/better-auth/better-auth/issues/1819#issuecomment-3003038278 * @customizations * - Simplified for single provider (Cognito) instead of generic multi-provider support * - Provider config passed directly as plugin options instead of runtime detection * - Added port preservation for localhost redirects * * This plugin enables OAuth flows to work across localhost, preview, and production * environments without requiring shared databases. It routes authentication through * production by encrypting state information using the OAuth provider's client secret. */ import type { BetterAuthPlugin } from "better-auth"; import { createAuthMiddleware } from "better-auth/api"; import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto"; // Prefix to identify proxy state parameters const STATE_PREFIX = "_proxy_"; /** * Encrypted state payload passed through OAuth provider */ interface OAuthProxyState { state: string; // Original state ID baseURL: string; // Originating server URL } /** * Better Auth OAuth redirect response */ interface BetterAuthResponse { url: string; } /** * Configuration for the OAuth proxy plugin */ interface OAuthProxyOptions { providerId: string; clientSecret: string; redirectURI: string; } /** * Creates the OAuth Proxy Plugin for Better Auth */ export function oAuthProxy(options: OAuthProxyOptions): BetterAuthPlugin { return { id: "oauth-proxy", hooks: { after: [ { matcher(context) { return context.path?.startsWith("/sign-in/oauth2"); }, handler: createAuthMiddleware(async (ctx) => { const logger = ctx.context.logger; const response = ctx.context.returned as BetterAuthResponse; if (!response?.url) { logger.warn("oauth-proxy: No response URL found"); return; } const oauthURL = new URL(response.url); const redirectURI = oauthURL.searchParams.get("redirect_uri"); if (!redirectURI) { logger.warn("oauth-proxy: No redirect_uri in request"); return; } // Don't proxy if redirecting to our own baseURL const baseURL = ctx.context.options.baseURL || process.env.BETTER_AUTH_URL || ""; if (redirectURI.startsWith(baseURL)) { return; } // Verify redirectURI matches configured production URL if (redirectURI !== options.redirectURI) { logger.warn( `oauth-proxy: redirectURI mismatch - expected ${options.redirectURI}`, ); return; } const state = oauthURL.searchParams.get("state"); if (!state) { logger.warn( `oauth-proxy[${options.providerId}]: No state parameter`, ); return; } // Encrypt state + baseURL with provider's client secret const proxyState: OAuthProxyState = { state, baseURL }; const encryptedPayload = await symmetricEncrypt({ key: options.clientSecret, data: JSON.stringify(proxyState), }); // Replace state with encrypted proxy state oauthURL.searchParams.set( "state", `${STATE_PREFIX}${encryptedPayload}`, ); response.url = oauthURL.toString(); logger.info( `oauth-proxy[${options.providerId}]: Proxied state for ${baseURL}`, ); }), }, ], before: [ { matcher(context) { return ( context.path?.startsWith("/callback") || context.path?.startsWith("/oauth2/callback") ); }, handler: createAuthMiddleware(async (ctx) => { const logger = ctx.context.logger; const requestUrl = ctx.request?.url; if (!requestUrl) { return; } const url = new URL(requestUrl); const state = url.searchParams.get("state"); // Only process if state has proxy prefix if (!state || !state.startsWith(STATE_PREFIX)) { return; } // Decrypt proxy state using provider's client secret const encryptedPayload = state.substring(STATE_PREFIX.length); const decryptedData = await symmetricDecrypt({ key: options.clientSecret, data: encryptedPayload, }); const proxyState: OAuthProxyState = JSON.parse(decryptedData); // Redirect to originating server with original state const redirectURL = new URL(requestUrl); const targetURL = new URL(proxyState.baseURL); redirectURL.host = targetURL.host; redirectURL.protocol = targetURL.protocol; redirectURL.port = targetURL.port; redirectURL.searchParams.set("state", proxyState.state); const redirectTo = redirectURL.toString(); logger.info( `oauth-proxy[${options.providerId}]: Redirecting to ${redirectTo}`, ); throw ctx.redirect(redirectTo); }), }, ], }, }; } ```
Author
Owner

@bytaesu commented on GitHub (Nov 2, 2025):

Hi,

I’ve rewritten the logic for the OAuth Proxy. The new implementation includes:

  • Resolved state-related errors
  • Resolved cross-origin issues (e.g. Vercel preview)
  • ... other logic improvements

The issue will be automatically closed once the PR is merged. If the problem persists afterward, please feel free to open a new issue!

@bytaesu commented on GitHub (Nov 2, 2025): Hi, I’ve rewritten the logic for the OAuth Proxy. The new implementation includes: - Resolved state-related errors - Resolved cross-origin issues (e.g. Vercel preview) - ... other logic improvements The issue will be automatically closed once the PR is merged. If the problem persists afterward, please feel free to open a new issue!
Author
Owner

@better-auth-agent[bot] commented on GitHub (Nov 2, 2025):

It looks like the new implementation should address the problems you were experiencing with cross-environment state mismatches. The updated logic now encrypts the state, bypassing the need to coordinate between separate databases, and it also handles cross-origin issues (like those seen in Vercel preview setups).

Next steps:
• Wait for the PR to merge, then deploy the update in your test/staging environment.
• Retry the OAuth flow from your development environment (or a preview URL) to production and verify that no state errors occur.
• Monitor your logs for any unexpected behavior during state decryption or error redirection.
• If the problem persists, gather any error details and open a new issue so that further diagnostics can be performed.

This approach follows the stateless state management proposal and should eliminate the issues related to cross-environment database discrepancies while also resolving cross-origin problems [1],[2].

DiagramDiscordGitHub

Diagram Join Star

@better-auth-agent[bot] commented on GitHub (Nov 2, 2025): It looks like the new implementation should address the problems you were experiencing with cross-environment state mismatches. The updated logic now encrypts the state, bypassing the need to coordinate between separate databases, and it also handles cross-origin issues (like those seen in Vercel preview setups). Next steps: • Wait for the PR to merge, then deploy the update in your test/staging environment. • Retry the OAuth flow from your development environment (or a preview URL) to production and verify that no state errors occur. • Monitor your logs for any unexpected behavior during state decryption or error redirection. • If the problem persists, gather any error details and open a new issue so that further diagnostics can be performed. This approach follows the stateless state management proposal and should eliminate the issues related to cross-environment database discrepancies while also resolving cross-origin problems [[1]](https://github.com/better-auth/better-auth/issues/1819),[[2]](https://github.com/better-auth/better-auth/pull/3125). <!-- 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)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#850