support cached cookie in bearer plugin #3038

Open
opened 2026-03-13 10:35:42 -05:00 by GiteaMirror · 0 comments
Owner

Originally created by @boyhax on GitHub (Mar 12, 2026).

Is this suited for github?

  • Yes, this is suited for github

Is your feature request related to a problem? Please describe.

when using bearer plugin the token is not holding any payload about user or session which cause always quering database on every request to know user id or roles . i asked every place of better auth people is reddit and discord and here in github about how to avoid database query on every get-session when using bearer token and no answer .

Describe the solution you'd like

so i made my custom bearer plugin that work but not tested in the better auth repo .i only tested it directly in my api using elysia and cloudflare workers .

import type { BetterAuthPlugin } from "@better-auth/core";
import { createAuthMiddleware } from "@better-auth/core/api";
import { serializeSignedCookie } from "better-call";
import { jwtVerify } from "jose";

function parseSetCookieHeader(
  header: string,
): Map<string, Record<string, string>> {
  const cookies = new Map<string, Record<string, string>>();
  const parts = header.split(/,(?=\s*\w+=)/);
  for (const part of parts) {
    const [cookiePart, ...attributeParts] = part.split(";");
    const eqIdx = cookiePart.trim().indexOf("=");
    if (eqIdx === -1) continue;
    const name = cookiePart.trim().slice(0, eqIdx);
    const value = cookiePart.trim().slice(eqIdx + 1);
    const attributes: Record<string, string> = {};
    for (const attr of attributeParts) {
      const attrEqIdx = attr.trim().indexOf("=");
      if (attrEqIdx === -1) {
        attributes[attr.trim().toLowerCase()] = "";
      } else {
        const attrName = attr.trim().slice(0, attrEqIdx);
        const attrValue = attr.trim().slice(attrEqIdx + 1);
        attributes[attrName.toLowerCase()] = attrValue;
      }
    }
    cookies.set(name, { value, ...attributes });
  }
  return cookies;
}
// declare module "@better-auth/core" {
//   interface BetterAuthPluginRegistry<AuthOptions, Options> {
//     bearer: {
//       creator: typeof bearer;
//     };
//   }
// }

export interface BearerOptions {
  /**
   * If true, only signed tokens
   * will be converted to session
   * cookies
   *
   * @default false
   */
  requireSignature?: boolean | undefined;
}

// RFC 7235: auth-scheme is case-insensitive
const BEARER_SCHEME = "bearer ";

function tryDecode(str: string): string {
  try {
    return decodeURIComponent(str);
  } catch {
    return str;
  }
}

async function verifyBearerJWT(
  authHeader: string | null | undefined,
  secret: string,
): Promise<Record<string, any> | null> {
  if (!authHeader) return null;
  if (authHeader.slice(0, BEARER_SCHEME.length).toLowerCase() !== BEARER_SCHEME)
    return null;
  const token = authHeader.slice(BEARER_SCHEME.length).trim();
  if (!token) return null;
  const secretBytes = new TextEncoder().encode(secret);
  const verified = await jwtVerify(token, secretBytes).catch(() => null);
  if (!verified) return null;
  if (verified.payload.exp) {
    const now = Math.floor(Date.now() / 1000);
    if (verified.payload.exp < now) return null;
  }
  return verified.payload as Record<string, any>;
}

/**
 * Converts bearer token to session cookie
 */
export const bearer = (options?: BearerOptions | undefined) => {
  return {
    id: "bearer",
    hooks: {
      before: [
        {
          // Short-circuit get-session: return plain object so both HTTP (asResponse:true)
          // and programmatic (asResponse:false) callers get { session, user } correctly.
          // A Response return only works for HTTP; programmatic calls receive the raw Response.
          matcher(context) {
            return Boolean(
              (context.request?.headers.get("authorization") ||
                context.headers?.get("authorization")) &&
                context.path?.includes("get-session"),
            );
          },
          handler: createAuthMiddleware(async (c) => {
            const authHeader =
              c.request?.headers.get("authorization") ||
              c.headers?.get("Authorization");
            const payload = await verifyBearerJWT(authHeader, c.context.secret);
            if (!payload?.session || !payload?.user) return;
            // Return a plain object — better-call returns it directly for programmatic
            // calls (asResponse:false) and wraps it in a JSON Response for HTTP calls.
            return { session: payload.session, user: payload.user };
          }),
        },
        {
          matcher(context) {
            return Boolean(
              context.request?.headers.get("authorization") ||
              context.headers?.get("authorization"),
            );
          },
          handler: createAuthMiddleware(async (c) => {
            const authHeader =
              c.request?.headers.get("authorization") ||
              c.headers?.get("Authorization");
            const payload = await verifyBearerJWT(authHeader, c.context.secret);
            if (!payload) return;

            const token = (authHeader as string).slice(BEARER_SCHEME.length).trim();

            // The bearer token is the session_data JWT — inject it as the
            // session_data cookie so better-auth returns it directly without
            // a DB query (works for both HTTP and auth.api.getSession() calls).
            const encodedToken = token.includes("%") ? token : encodeURIComponent(token);
            const existingHeaders = (c.request?.headers || c.headers) as Headers;
            const headers = new Headers({
              ...Object.fromEntries(existingHeaders?.entries()),
            });
            const existingCookie = headers.get("cookie");
            // Inject the bearer JWT as session_data cookie for cache-based get-session
            const sessionDataCookie = `${c.context.authCookies.sessionData.name}=${encodedToken}`;
            // Reconstruct the signed session_token cookie so getSignedCookie() passes
            // verification — format expected by better-auth is `rawToken.hmacSignature`
            const rawSessionToken = (payload as any)?.session?.token as string | undefined;
            let sessionTokenCookie: string | null = null;
            if (rawSessionToken) {
              const signed = await serializeSignedCookie(
                "",
                rawSessionToken,
                c.context.secret,
              );
              // serializeSignedCookie("", value, secret) → "=value.sig"; strip leading "="
              const signedValue = signed.replace(/^=/, "");
              sessionTokenCookie = `${c.context.authCookies.sessionToken.name}=${signedValue}`;
            }
            const newCookieParts = [sessionDataCookie];
            if (sessionTokenCookie) newCookieParts.push(sessionTokenCookie);
            headers.set(
              "cookie",
              existingCookie
                ? `${existingCookie}; ${newCookieParts.join("; ")}`
                : newCookieParts.join("; "),
            );
            return {
              context: {
                headers,
              },
            };
          }),
        },
      ],
      after: [
        {
          matcher(context) {
            return true;
          },
          handler: createAuthMiddleware(async (ctx) => {
            const setCookie = ctx.context.responseHeaders?.getSetCookie();
            if (!setCookie?.length) {
              return;
            }
            let token = "";
            const sessionDataCookieName =
              ctx.context.authCookies.sessionData.name;
            for (const cookie of setCookie) {
              const parsedCookies = parseSetCookieHeader(cookie);
              const sessionDataCookie = parsedCookies.get(
                sessionDataCookieName,
              );
              if (sessionDataCookie?.value) {
                token = tryDecode(sessionDataCookie.value);
                break;
              }
            }
            if (!token) {
              return;
            }

            const exposedHeaders =
              ctx.context.responseHeaders?.get(
                "access-control-expose-headers",
              ) || "";
            const headersSet = new Set(
              exposedHeaders
                .split(",")
                .map((header) => header.trim())
                .filter(Boolean),
            );
            headersSet.add("set-auth-token");
            ctx.setHeader("set-auth-token", token);
            ctx.setHeader(
              "Access-Control-Expose-Headers",
              Array.from(headersSet).join(", "),
            );
          }),
        },
      ],
    },
    options,
  } satisfies BetterAuthPlugin;
};

this the custom plugin im sure it need more work to be compatible with current version and also need work to support other toekn types used in cached cookies like jwe and compact.

is this possible solution ? or there problem im missing here .

Describe alternatives you've considered

also jwt plugin is not an option because it dont return jwt token in sign in or signup and it need it own table and also dont return the session when calling get-session i dont now why and jwt plugin made to support auth in outside the same api .

Additional context

No response

Originally created by @boyhax on GitHub (Mar 12, 2026). ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. when using bearer plugin the token is not holding any payload about user or session which cause always quering database on every request to know user id or roles . i asked every place of better auth people is reddit and discord and here in github about how to avoid database query on every get-session when using bearer token and no answer . ### Describe the solution you'd like so i made my custom bearer plugin that work but not tested in the better auth repo .i only tested it directly in my api using elysia and cloudflare workers . ``` import type { BetterAuthPlugin } from "@better-auth/core"; import { createAuthMiddleware } from "@better-auth/core/api"; import { serializeSignedCookie } from "better-call"; import { jwtVerify } from "jose"; function parseSetCookieHeader( header: string, ): Map<string, Record<string, string>> { const cookies = new Map<string, Record<string, string>>(); const parts = header.split(/,(?=\s*\w+=)/); for (const part of parts) { const [cookiePart, ...attributeParts] = part.split(";"); const eqIdx = cookiePart.trim().indexOf("="); if (eqIdx === -1) continue; const name = cookiePart.trim().slice(0, eqIdx); const value = cookiePart.trim().slice(eqIdx + 1); const attributes: Record<string, string> = {}; for (const attr of attributeParts) { const attrEqIdx = attr.trim().indexOf("="); if (attrEqIdx === -1) { attributes[attr.trim().toLowerCase()] = ""; } else { const attrName = attr.trim().slice(0, attrEqIdx); const attrValue = attr.trim().slice(attrEqIdx + 1); attributes[attrName.toLowerCase()] = attrValue; } } cookies.set(name, { value, ...attributes }); } return cookies; } // declare module "@better-auth/core" { // interface BetterAuthPluginRegistry<AuthOptions, Options> { // bearer: { // creator: typeof bearer; // }; // } // } export interface BearerOptions { /** * If true, only signed tokens * will be converted to session * cookies * * @default false */ requireSignature?: boolean | undefined; } // RFC 7235: auth-scheme is case-insensitive const BEARER_SCHEME = "bearer "; function tryDecode(str: string): string { try { return decodeURIComponent(str); } catch { return str; } } async function verifyBearerJWT( authHeader: string | null | undefined, secret: string, ): Promise<Record<string, any> | null> { if (!authHeader) return null; if (authHeader.slice(0, BEARER_SCHEME.length).toLowerCase() !== BEARER_SCHEME) return null; const token = authHeader.slice(BEARER_SCHEME.length).trim(); if (!token) return null; const secretBytes = new TextEncoder().encode(secret); const verified = await jwtVerify(token, secretBytes).catch(() => null); if (!verified) return null; if (verified.payload.exp) { const now = Math.floor(Date.now() / 1000); if (verified.payload.exp < now) return null; } return verified.payload as Record<string, any>; } /** * Converts bearer token to session cookie */ export const bearer = (options?: BearerOptions | undefined) => { return { id: "bearer", hooks: { before: [ { // Short-circuit get-session: return plain object so both HTTP (asResponse:true) // and programmatic (asResponse:false) callers get { session, user } correctly. // A Response return only works for HTTP; programmatic calls receive the raw Response. matcher(context) { return Boolean( (context.request?.headers.get("authorization") || context.headers?.get("authorization")) && context.path?.includes("get-session"), ); }, handler: createAuthMiddleware(async (c) => { const authHeader = c.request?.headers.get("authorization") || c.headers?.get("Authorization"); const payload = await verifyBearerJWT(authHeader, c.context.secret); if (!payload?.session || !payload?.user) return; // Return a plain object — better-call returns it directly for programmatic // calls (asResponse:false) and wraps it in a JSON Response for HTTP calls. return { session: payload.session, user: payload.user }; }), }, { matcher(context) { return Boolean( context.request?.headers.get("authorization") || context.headers?.get("authorization"), ); }, handler: createAuthMiddleware(async (c) => { const authHeader = c.request?.headers.get("authorization") || c.headers?.get("Authorization"); const payload = await verifyBearerJWT(authHeader, c.context.secret); if (!payload) return; const token = (authHeader as string).slice(BEARER_SCHEME.length).trim(); // The bearer token is the session_data JWT — inject it as the // session_data cookie so better-auth returns it directly without // a DB query (works for both HTTP and auth.api.getSession() calls). const encodedToken = token.includes("%") ? token : encodeURIComponent(token); const existingHeaders = (c.request?.headers || c.headers) as Headers; const headers = new Headers({ ...Object.fromEntries(existingHeaders?.entries()), }); const existingCookie = headers.get("cookie"); // Inject the bearer JWT as session_data cookie for cache-based get-session const sessionDataCookie = `${c.context.authCookies.sessionData.name}=${encodedToken}`; // Reconstruct the signed session_token cookie so getSignedCookie() passes // verification — format expected by better-auth is `rawToken.hmacSignature` const rawSessionToken = (payload as any)?.session?.token as string | undefined; let sessionTokenCookie: string | null = null; if (rawSessionToken) { const signed = await serializeSignedCookie( "", rawSessionToken, c.context.secret, ); // serializeSignedCookie("", value, secret) → "=value.sig"; strip leading "=" const signedValue = signed.replace(/^=/, ""); sessionTokenCookie = `${c.context.authCookies.sessionToken.name}=${signedValue}`; } const newCookieParts = [sessionDataCookie]; if (sessionTokenCookie) newCookieParts.push(sessionTokenCookie); headers.set( "cookie", existingCookie ? `${existingCookie}; ${newCookieParts.join("; ")}` : newCookieParts.join("; "), ); return { context: { headers, }, }; }), }, ], after: [ { matcher(context) { return true; }, handler: createAuthMiddleware(async (ctx) => { const setCookie = ctx.context.responseHeaders?.getSetCookie(); if (!setCookie?.length) { return; } let token = ""; const sessionDataCookieName = ctx.context.authCookies.sessionData.name; for (const cookie of setCookie) { const parsedCookies = parseSetCookieHeader(cookie); const sessionDataCookie = parsedCookies.get( sessionDataCookieName, ); if (sessionDataCookie?.value) { token = tryDecode(sessionDataCookie.value); break; } } if (!token) { return; } const exposedHeaders = ctx.context.responseHeaders?.get( "access-control-expose-headers", ) || ""; const headersSet = new Set( exposedHeaders .split(",") .map((header) => header.trim()) .filter(Boolean), ); headersSet.add("set-auth-token"); ctx.setHeader("set-auth-token", token); ctx.setHeader( "Access-Control-Expose-Headers", Array.from(headersSet).join(", "), ); }), }, ], }, options, } satisfies BetterAuthPlugin; }; ``` this the custom plugin im sure it need more work to be compatible with current version and also need work to support other toekn types used in cached cookies like jwe and compact. is this possible solution ? or there problem im missing here . ### Describe alternatives you've considered also jwt plugin is not an option because it dont return jwt token in sign in or signup and it need it own table and also dont return the session when calling get-session i dont now why and jwt plugin made to support auth in outside the same api . ### Additional context _No response_
GiteaMirror added the plugin label 2026-03-13 10:35:42 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#3038