[GH-ISSUE #3462] 401 / No session despite a fresh session, happens randomly #18242

Closed
opened 2026-04-15 16:39:05 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @izadoesdev on GitHub (Jul 19, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3462

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Create the tRPC backend, add the auth config, idle on tab then call API without refreshing page

(Not sure if it can be replicated, since I can't pinpoint the issue myself)

Current vs. Expected behavior

I'm using Next.js with tRPC, the main procedure gets the user's session directly from the request headers via the context, code below

Context:

export const createTRPCContext = async (opts: { headers: Headers }) => {
  const session = await auth.api.getSession({
    headers: opts.headers,
  });

  return {
    db,
    auth,
    session: session?.session,
    user: session?.user,
    ...opts,
  };
};

And the procedure to check authorization:

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.user || !ctx.session) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }

  return next({
    ctx: {
      ...ctx,
      session: ctx.session,
      user: ctx.user,
    },
  });
});

What ends up happening is in most cases, it works, but leaving the tab open for some time and trying to call the API returns 401, which persists until the user refreshes multiple times then it works again for a while, seems to happen on idle

The response is directly from the protectedProcedure, the TRPCError code UNAUTHORIZED is what returns to the user

What version of Better Auth are you using?

1.30.11 (but also had it on 1.2.12)

Provide environment information

- OS: Windows 11
- Browser: Brave (But likely irrelevant)

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

Backend, Client

Auth config (if applicable)

export const auth = betterAuth({
    database: drizzleAdapter(db, {
        provider: "pg",
    }),
    databaseHooks: {
        user: {
            create: {
                after: async (user: { id: string; name: string; email: string }) => {
                    logger.info('User Created', `User ${user.id}, ${user.name}, ${user.email} created`);
                    // const resend = new Resend(process.env.RESEND_API_KEY as string);
                    // await resend.emails.send({
                    //     from: "Databuddy <noreply@databuddy.cc>",
                    //     to: user.email,
                    //     subject: "Welcome to Databuddy",
                    //     react: WelcomeEmail({ username: user.name, url: process.env.BETTER_AUTH_URL as string }),
                    // });
                },
            },
        },
    },
    user: {
        deleteUser: {
            enabled: true,
            beforeDelete: async (user) => {
                logger.info('User Deletion Started', `Starting deletion process for user ${user.id}`);

                try {
                    const userWebsites = await db.query.websites.findMany({
                        where: eq(websites.userId, user.id)
                    });

                    if (userWebsites.length > 0) {
                        logger.info('Deleting Websites', `Deleting websites for user ${user.id}`);
                        await db.delete(websites).where(inArray(websites.id, userWebsites.map(w => w.id)));
                    }
                    logger.info('User Deletion Finished', `Finished deletion process for user ${user.id}`);
                } catch (error) {
                    logger.exception(error as Error, { user: user.id });
                }
            }
        }
    },
    appName: "databuddy.cc",
    onAPIError: {
        throw: false,
        onError: (error, ctx) => {
            if (error instanceof Error) {
                logger.exception(error, ctx as Record<string, unknown>);
            } else {
                logger.error("Auth API Error", "An unknown error occurred", {
                    error,
                    ...(ctx as Record<string, unknown>)
                });
            }
        },
        errorURL: "/auth/error"
    },
    advanced: {
        crossSubDomainCookies: {
            enabled: isProduction(),
            domain: ".databuddy.cc"
        },
        cookiePrefix: "databuddy",
        useSecureCookies: isProduction()
    },
    trustedOrigins: [
        'https://databuddy.cc',
        'https://app.databuddy.cc',
        'https://api.databuddy.cc'
    ],
    socialProviders: {
        google: {
            clientId: process.env.GOOGLE_CLIENT_ID as string,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
        },
        github: {
            clientId: process.env.GITHUB_CLIENT_ID as string,
            clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
        },
    },
    emailAndPassword: {
        enabled: true,
        minPasswordLength: 8,
        maxPasswordLength: 32,
        autoSignIn: false,
        requireEmailVerification: true,
        sendResetPasswordEmail: async ({ user, url }: { user: any, url: string }) => {
            const resend = new Resend(process.env.RESEND_API_KEY as string);
            await resend.emails.send({
                from: 'noreply@databuddy.cc',
                to: user.email,
                subject: 'Reset your password',
                react: ResetPasswordEmail({ url })
            });
        }
    },
    emailVerification: {
        sendOnSignUp: true,
        sendVerificationOnSignUp: true,
        disableSignUp: true,
        sendVerificationOnSignIn: true,
        autoSignInAfterVerification: true,
        sendVerificationEmail: async ({ user, url }: { user: any, url: string }) => {
            logger.info('Email Verification', `Sending verification email to ${user.email}`);
            const resend = new Resend(process.env.RESEND_API_KEY as string);
            await resend.emails.send({
                from: 'noreply@databuddy.cc',
                to: user.email,
                subject: 'Verify your email',
                react: VerificationEmail({ url })
            });
        }
    },
    session: {
        cookieCache: {
            enabled: true,
            maxAge: 60 * 60 * 24 * 30, // 30 days
        },
        expiresIn: 60 * 60 * 24 * 30, // 30 days
        updateAge: 60 * 60 * 24 * 3, // 1 day (every 1 day the session expiration is updated)
    },
    secondaryStorage: {
        get: async (key) => {
            const value = await getRedisCache()?.get(key);
            return value ? value : null;
        },
        set: async (key, value, ttl = 60 * 60 * 24) => {
            await getRedisCache()?.setex(key, ttl, value);
        },
        delete: async (key) => {
            await getRedisCache()?.del(key);
        },
    },
    plugins: [
        emailOTP({
            async sendVerificationOTP({ email, otp, type }) {
                logger.info('Email OTP', `Sending OTP to ${email} of type ${type}`);
                const resend = new Resend(process.env.RESEND_API_KEY as string);
                await resend.emails.send({
                    from: 'noreply@databuddy.cc',
                    to: email,
                    subject: 'Your verification code',
                    react: OtpEmail({ otp })
                })
            },
        }),
        magicLink({
            sendMagicLink: async ({ email, token, url }) => {
                logger.info('Magic Link', `Sending magic link to ${email}`);
                const resend = new Resend(process.env.RESEND_API_KEY as string);
                await resend.emails.send({
                    from: 'noreply@databuddy.cc',
                    to: email,
                    subject: 'Login to Databuddy',
                    react: MagicLinkEmail({ url })
                });
            }
        }),
        twoFactor(),
        customSession(async ({ user: sessionUser, session }) => {
            const [dbUser] = await db.query.user.findMany({
                where: eq(user.id, session.userId),
                columns: {
                    role: true,
                }
            });
            return {
                role: dbUser?.role,
                user: {
                    ...sessionUser,
                    role: dbUser?.role,
                },
                session
            };
        }),
        organization({
            creatorRole: "owner",
            teams: {
                enabled: false,
            },
            ac,
            roles: {
                owner,
                admin,
                member,
                viewer,
            },
            sendInvitationEmail: async ({ email, inviter, organization, invitation }) => {
                logger.info(
                    'Organization Invitation',
                    `Inviting ${email} to ${organization.name}`,
                    { inviter: inviter.user.name, organizationId: organization.id }
                );
                const invitationLink = `https://app.databuddy.cc/invitations/${invitation.id}`;
                const resend = new Resend(process.env.RESEND_API_KEY as string);
                await resend.emails.send({
                    from: 'noreply@databuddy.cc',
                    to: email,
                    subject: `You're invited to join ${organization.name}`,
                    react: InvitationEmail({ inviterName: inviter.user.name, organizationName: organization.name, invitationLink: invitationLink })
                });
            }
        })
    ]
})

Additional context

It's still happening on the beta of 1.30.11

Originally created by @izadoesdev on GitHub (Jul 19, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3462 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce Create the tRPC backend, add the auth config, idle on tab then call API without refreshing page (Not sure if it can be replicated, since I can't pinpoint the issue myself) ### Current vs. Expected behavior I'm using Next.js with tRPC, the main procedure gets the user's session directly from the request headers via the context, code below Context: ```ts export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await auth.api.getSession({ headers: opts.headers, }); return { db, auth, session: session?.session, user: session?.user, ...opts, }; }; ``` And the procedure to check authorization: ```ts export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.user || !ctx.session) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, session: ctx.session, user: ctx.user, }, }); }); ``` What ends up happening is in most cases, it works, but leaving the tab open for some time and trying to call the API returns 401, which persists until the user refreshes multiple times then it works again for a while, seems to happen on idle The response is directly from the protectedProcedure, the TRPCError code UNAUTHORIZED is what returns to the user ### What version of Better Auth are you using? 1.30.11 (but also had it on 1.2.12) ### Provide environment information ```bash - OS: Windows 11 - Browser: Brave (But likely irrelevant) ``` ### Which area(s) are affected? (Select all that apply) Backend, Client ### Auth config (if applicable) ```typescript export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", }), databaseHooks: { user: { create: { after: async (user: { id: string; name: string; email: string }) => { logger.info('User Created', `User ${user.id}, ${user.name}, ${user.email} created`); // const resend = new Resend(process.env.RESEND_API_KEY as string); // await resend.emails.send({ // from: "Databuddy <noreply@databuddy.cc>", // to: user.email, // subject: "Welcome to Databuddy", // react: WelcomeEmail({ username: user.name, url: process.env.BETTER_AUTH_URL as string }), // }); }, }, }, }, user: { deleteUser: { enabled: true, beforeDelete: async (user) => { logger.info('User Deletion Started', `Starting deletion process for user ${user.id}`); try { const userWebsites = await db.query.websites.findMany({ where: eq(websites.userId, user.id) }); if (userWebsites.length > 0) { logger.info('Deleting Websites', `Deleting websites for user ${user.id}`); await db.delete(websites).where(inArray(websites.id, userWebsites.map(w => w.id))); } logger.info('User Deletion Finished', `Finished deletion process for user ${user.id}`); } catch (error) { logger.exception(error as Error, { user: user.id }); } } } }, appName: "databuddy.cc", onAPIError: { throw: false, onError: (error, ctx) => { if (error instanceof Error) { logger.exception(error, ctx as Record<string, unknown>); } else { logger.error("Auth API Error", "An unknown error occurred", { error, ...(ctx as Record<string, unknown>) }); } }, errorURL: "/auth/error" }, advanced: { crossSubDomainCookies: { enabled: isProduction(), domain: ".databuddy.cc" }, cookiePrefix: "databuddy", useSecureCookies: isProduction() }, trustedOrigins: [ 'https://databuddy.cc', 'https://app.databuddy.cc', 'https://api.databuddy.cc' ], socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, }, github: { clientId: process.env.GITHUB_CLIENT_ID as string, clientSecret: process.env.GITHUB_CLIENT_SECRET as string, }, }, emailAndPassword: { enabled: true, minPasswordLength: 8, maxPasswordLength: 32, autoSignIn: false, requireEmailVerification: true, sendResetPasswordEmail: async ({ user, url }: { user: any, url: string }) => { const resend = new Resend(process.env.RESEND_API_KEY as string); await resend.emails.send({ from: 'noreply@databuddy.cc', to: user.email, subject: 'Reset your password', react: ResetPasswordEmail({ url }) }); } }, emailVerification: { sendOnSignUp: true, sendVerificationOnSignUp: true, disableSignUp: true, sendVerificationOnSignIn: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }: { user: any, url: string }) => { logger.info('Email Verification', `Sending verification email to ${user.email}`); const resend = new Resend(process.env.RESEND_API_KEY as string); await resend.emails.send({ from: 'noreply@databuddy.cc', to: user.email, subject: 'Verify your email', react: VerificationEmail({ url }) }); } }, session: { cookieCache: { enabled: true, maxAge: 60 * 60 * 24 * 30, // 30 days }, expiresIn: 60 * 60 * 24 * 30, // 30 days updateAge: 60 * 60 * 24 * 3, // 1 day (every 1 day the session expiration is updated) }, secondaryStorage: { get: async (key) => { const value = await getRedisCache()?.get(key); return value ? value : null; }, set: async (key, value, ttl = 60 * 60 * 24) => { await getRedisCache()?.setex(key, ttl, value); }, delete: async (key) => { await getRedisCache()?.del(key); }, }, plugins: [ emailOTP({ async sendVerificationOTP({ email, otp, type }) { logger.info('Email OTP', `Sending OTP to ${email} of type ${type}`); const resend = new Resend(process.env.RESEND_API_KEY as string); await resend.emails.send({ from: 'noreply@databuddy.cc', to: email, subject: 'Your verification code', react: OtpEmail({ otp }) }) }, }), magicLink({ sendMagicLink: async ({ email, token, url }) => { logger.info('Magic Link', `Sending magic link to ${email}`); const resend = new Resend(process.env.RESEND_API_KEY as string); await resend.emails.send({ from: 'noreply@databuddy.cc', to: email, subject: 'Login to Databuddy', react: MagicLinkEmail({ url }) }); } }), twoFactor(), customSession(async ({ user: sessionUser, session }) => { const [dbUser] = await db.query.user.findMany({ where: eq(user.id, session.userId), columns: { role: true, } }); return { role: dbUser?.role, user: { ...sessionUser, role: dbUser?.role, }, session }; }), organization({ creatorRole: "owner", teams: { enabled: false, }, ac, roles: { owner, admin, member, viewer, }, sendInvitationEmail: async ({ email, inviter, organization, invitation }) => { logger.info( 'Organization Invitation', `Inviting ${email} to ${organization.name}`, { inviter: inviter.user.name, organizationId: organization.id } ); const invitationLink = `https://app.databuddy.cc/invitations/${invitation.id}`; const resend = new Resend(process.env.RESEND_API_KEY as string); await resend.emails.send({ from: 'noreply@databuddy.cc', to: email, subject: `You're invited to join ${organization.name}`, react: InvitationEmail({ inviterName: inviter.user.name, organizationName: organization.name, invitationLink: invitationLink }) }); } }) ] }) ``` ### Additional context It's still happening on the beta of 1.30.11
GiteaMirror added the locked label 2026-04-15 16:39:05 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Jul 19, 2025):

Hi @izadoesdev! I'm Dosu and I’m helping the better-auth team.

This kind of random 401/no session after idling is often caused by desynchronization between the cookieCache, cookies, and secondaryStorage layers in Better Auth. When a tab is idle, the session data cached in cookies can expire or become invalid, but the client may not realize this until it tries to make an API call. If the session is expired, invalid, or the cache is stale, Better Auth will return null and your protectedProcedure will throw UNAUTHORIZED. This can persist until a full refresh clears out the stale cookies and re-establishes a valid session context.

A few things to try:

  • Enable detailed logging on both client and server to see if the session cookie or session data is missing, expired, or invalid when the 401 happens.
  • Test with cookieCache disabled (cookieCache: { enabled: false }) to see if the issue is related to cookie-based caching.
  • Try setting the disableSessionRefresh option to true to prevent session refresh logic from interfering after idle, or adjust the updateAge to a lower value so sessions refresh more frequently and are less likely to go stale after idle periods (source).
  • Consider triggering a silent session refresh (e.g., a background call to /get-session) when the tab regains focus, to proactively update the session before the user makes an API call.
  • Make sure all API calls always include the correct headers and cookies, especially if you are using cross-domain or subdomain setups (source).

There was also a bug in older versions of Better Auth where cookieCache could return null due to improper decoding, but this was fixed in later releases (source). Since you’re on 1.30.11, you should have that fix, but if you have any custom session logic or plugins, double-check that they aren’t interfering with session reads/writes.

If you can reliably reproduce the issue with cookieCache enabled but not when it’s disabled, that’s a strong indicator the problem is with cookie-based session caching and not your database or Redis secondary storage.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

<!-- gh-comment-id:3091962612 --> @dosubot[bot] commented on GitHub (Jul 19, 2025): <!-- Greeting --> Hi @izadoesdev! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This kind of random 401/no session after idling is often caused by desynchronization between the cookieCache, cookies, and secondaryStorage layers in Better Auth. When a tab is idle, the session data cached in cookies can expire or become invalid, but the client may not realize this until it tries to make an API call. If the session is expired, invalid, or the cache is stale, Better Auth will return null and your protectedProcedure will throw UNAUTHORIZED. This can persist until a full refresh clears out the stale cookies and re-establishes a valid session context. A few things to try: - Enable detailed logging on both client and server to see if the session cookie or session data is missing, expired, or invalid when the 401 happens. - Test with cookieCache disabled (`cookieCache: { enabled: false }`) to see if the issue is related to cookie-based caching. - Try setting the `disableSessionRefresh` option to true to prevent session refresh logic from interfering after idle, or adjust the `updateAge` to a lower value so sessions refresh more frequently and are less likely to go stale after idle periods ([source](https://github.com/better-auth/better-auth/pull/2126)). - Consider triggering a silent session refresh (e.g., a background call to `/get-session`) when the tab regains focus, to proactively update the session before the user makes an API call. - Make sure all API calls always include the correct headers and cookies, especially if you are using cross-domain or subdomain setups ([source](https://github.com/better-auth/better-auth/issues/3297)). There was also a bug in older versions of Better Auth where cookieCache could return null due to improper decoding, but this was fixed in later releases ([source](https://github.com/better-auth/better-auth/issues/2941)). Since you’re on 1.30.11, you should have that fix, but if you have any custom session logic or plugins, double-check that they aren’t interfering with session reads/writes. If you can reliably reproduce the issue with cookieCache enabled but not when it’s disabled, that’s a strong indicator the problem is with cookie-based session caching and not your database or Redis secondary storage. <!-- 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/ec379bd5-66b7-4697-a7fa-bf6a572b286f?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/ec379bd5-66b7-4697-a7fa-bf6a572b286f?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/ec379bd5-66b7-4697-a7fa-bf6a572b286f?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/ec379bd5-66b7-4697-a7fa-bf6a572b286f?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/ec379bd5-66b7-4697-a7fa-bf6a572b286f?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/ec379bd5-66b7-4697-a7fa-bf6a572b286f?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/ec379bd5-66b7-4697-a7fa-bf6a572b286f?feedback_type=other)</sup>&nbsp;&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/3462)
Author
Owner

@Bekacru commented on GitHub (Jul 24, 2025):

If the issue is related to the custom session, try disabling it and see if the problem persists. But generally seems like it's an issue either with how headers are passed to better auth or something of that sort. If you can create a minimal repro, I’d be happy to take a look.

<!-- gh-comment-id:3112074143 --> @Bekacru commented on GitHub (Jul 24, 2025): If the issue is related to the custom session, try disabling it and see if the problem persists. But generally seems like it's an issue either with how headers are passed to better auth or something of that sort. If you can create a minimal repro, I’d be happy to take a look.
Author
Owner

@ping-maxwell commented on GitHub (Jul 26, 2025):

This might be related to https://github.com/better-auth/better-auth/pull/3283

<!-- gh-comment-id:3122182883 --> @ping-maxwell commented on GitHub (Jul 26, 2025): This might be related to https://github.com/better-auth/better-auth/pull/3283
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#18242