EXPO plugin SignOut does not remove the Session #2277

Open
opened 2026-03-13 09:40:36 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @Tranthanh98 on GitHub (Nov 9, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

I'm using Expo Plugin,
when I sign out, it's not remove the session in the database, the session table still has an record.
I test with open API, it works well

Current vs. Expected behavior

It should be delete a session record when I logout, like open API behaviour

What version of Better Auth are you using?

1.3.32

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 24.5.0: Tue Apr 22 19:48:46 PDT 2025; root:xnu-11417.121.6~2/RELEASE_ARM64_T8103",
    "release": "24.5.0",
    "cpuCount": 8,
    "cpuModel": "Apple M1",
    "totalMemory": "8.00 GB",
    "freeMemory": "0.14 GB"
  },
  "node": {
    "version": "v23.11.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.4.2"
  },
  "frameworks": [
    {
      "name": "express",
      "version": "^5.1.0"
    }
  ],
  "databases": [
    {
      "name": "pg",
      "version": "^8.16.3"
    },
    {
      "name": "drizzle",
      "version": "^0.44.7"
    }
  ],
  "betterAuth": {
    "version": "^1.3.32",
    "config": {
      "emailAndPassword": {
        "enabled": true,
        "requireEmailVerification": false,
        "minPasswordLength": 6,
        "maxPasswordLength": 18,
        "autoSignIn": true
      },
      "secret": "[REDACTED]",
      "plugins": [
        {
          "name": "expo",
          "config": {
            "id": "expo",
            "hooks": {
              "after": [
                {}
              ]
            },
            "endpoints": {}
          }
        },
        {
          "name": "open-api",
          "config": {
            "id": "open-api",
            "endpoints": {}
          }
        },
        {
          "name": "bearer",
          "config": {
            "id": "bearer",
            "hooks": {
              "before": [
                {}
              ],
              "after": [
                {}
              ]
            }
          }
        },
        {
          "name": "email-otp",
          "config": {
            "id": "email-otp",
            "endpoints": {},
            "hooks": {
              "after": [
                {}
              ]
            },
            "$ERROR_CODES": {
              "OTP_EXPIRED": "otp expired",
              "INVALID_OTP": "Invalid OTP",
              "INVALID_EMAIL": "Invalid email",
              "USER_NOT_FOUND": "User not found",
              "TOO_MANY_ATTEMPTS": "Too many attempts"
            },
            "rateLimit": [
              {
                "window": 60,
                "max": 3
              },
              {
                "window": 60,
                "max": 3
              },
              {
                "window": 60,
                "max": 3
              },
              {
                "window": 60,
                "max": 3
              }
            ]
          }
        }
      ],
      "advanced": {
        "database": {}
      },
      "trustedOrigins": [
        "loopie://",
        "exp://",
        "http://localhost:8081"
      ],
      "session": {
        "expiresIn": 2592000,
        "updateAge": 86400,
        "cookieCache": {
          "enabled": true,
          "maxAge": 3600
        }
      },
      "socialProviders": {
        "google": {
          "enabled": true,
          "clientId": "[REDACTED]"
        }
      },
      "user": {
        "modelName": "profiles",
        "fields": {
          "name": "displayName",
          "image": "avatarUrl",
          "emailVerified": "isVerified"
        },
        "additionalFields": {
          "phone": {
            "type": "string",
            "required": false
          },
          "username": {
            "type": "string",
            "required": true
          },
          "region": {
            "type": "string",
            "required": false
          },
          "countryCode": {
            "type": "string",
            "required": false
          },
          "plan": {
            "type": "string",
            "required": true,
            "defaultValue": "free",
            "input": false
          },
          "language": {
            "type": "string",
            "required": false
          },
          "friendsCount": {
            "type": "number",
            "required": true,
            "defaultValue": 0
          },
          "photosShared": {
            "type": "number",
            "required": true,
            "defaultValue": 0
          },
          "dob": {
            "type": "date",
            "required": false
          },
          "bio": {
            "type": "string",
            "required": false
          },
          "location": {
            "type": "string",
            "required": false
          }
        },
        "changeEmail": {
          "enabled": true
        }
      },
      "hooks": {}
    }
  }
}

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

Client

Auth config (if applicable)

import { expo } from "@better-auth/expo";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import {
  bearer,
  createAuthMiddleware,
  emailOTP,
  openAPI,
} from "better-auth/plugins";
import { eq } from "drizzle-orm";
import { db } from "../database/connection";
import { profiles } from "../database/schema/auth-schema";
import emailTemplateService from "../services/emailService/emailTemplateService";
import { generateUsernameFromEmail } from "../utils/generateUsernameFromEmail";

export const auth: any = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
  }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: false,
    minPasswordLength: 6,
    maxPasswordLength: 18,
    autoSignIn: true,
  },
  secret: process.env.AUTH_SECRET || "auth-secret",
  plugins: [
    expo(),
    openAPI(),
    bearer(),
    emailOTP({
      async sendVerificationOTP({ email, otp, type }) {
        if (type === "sign-in") {
          emailTemplateService.sendVerificationOTP(email, otp);
        } else if (type === "email-verification") {
          // Send the OTP for email verification
        } else {
          emailTemplateService.sendResetPassword(email, otp);
        }
      },
    }),
  ],
  advanced: {
    database: {
      generateId: () => crypto.randomUUID(),
    },
  },
  trustedOrigins: [
    "loopie://", // Your app scheme
    "exp://", // Expo development
    "http://localhost:8081", // Expo development server
  ],
  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 days
    updateAge: 60 * 60 * 24, // 1 day
    cookieCache: {
      enabled: true,
      maxAge: 60 * 60, // 1 hour
    },
  },
  socialProviders: {
    google: {
      enabled: true,
      clientId: process.env.GOOGLE_CLIENT_ID || "",
      mapProfileToUser: (profile) => ({
        displayName: profile.name,
        username: generateUsernameFromEmail(profile.email),
        avatarUrl: profile.picture,
        email: profile.email,
        isVerified: profile.email_verified,
      }),
    },
  },
  user: {
    modelName: "profiles",
    fields: {
      name: "displayName",
      image: "avatarUrl",
      emailVerified: "isVerified",
    },
    additionalFields: {
      phone: {
        type: "string",
        required: false,
      },
      username: {
        type: "string",
        required: true,
        defaultValue: () => {
          // This will be overridden in hooks for proper username generation
          return `tempuser_${Math.random().toString(36).substring(2, 10)}`;
        },
      },
      region: {
        type: "string",
        required: false,
      },
      countryCode: {
        type: "string",
        required: false,
      },
      plan: {
        type: "string",
        required: true,
        defaultValue: "free",
        input: false,
      },
      language: {
        type: "string",
        required: false,
      },
      friendsCount: {
        type: "number",
        required: true,
        defaultValue: 0,
      },
      photosShared: {
        type: "number",
        required: true,
        defaultValue: 0,
      },
      dob: {
        type: "date",
        required: false,
      },
      bio: {
        type: "string",
        required: false,
      },
      location: {
        type: "string",
        required: false,
      },
    },
    changeEmail: {
      enabled: true,
    },
  },
});

Additional context

No response

Originally created by @Tranthanh98 on GitHub (Nov 9, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce I'm using Expo Plugin, when I sign out, it's not remove the session in the database, the `session` table still has an record. I test with open API, it works well ### Current vs. Expected behavior It should be delete a session record when I logout, like open API behaviour ### What version of Better Auth are you using? 1.3.32 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 24.5.0: Tue Apr 22 19:48:46 PDT 2025; root:xnu-11417.121.6~2/RELEASE_ARM64_T8103", "release": "24.5.0", "cpuCount": 8, "cpuModel": "Apple M1", "totalMemory": "8.00 GB", "freeMemory": "0.14 GB" }, "node": { "version": "v23.11.0", "env": "development" }, "packageManager": { "name": "npm", "version": "11.4.2" }, "frameworks": [ { "name": "express", "version": "^5.1.0" } ], "databases": [ { "name": "pg", "version": "^8.16.3" }, { "name": "drizzle", "version": "^0.44.7" } ], "betterAuth": { "version": "^1.3.32", "config": { "emailAndPassword": { "enabled": true, "requireEmailVerification": false, "minPasswordLength": 6, "maxPasswordLength": 18, "autoSignIn": true }, "secret": "[REDACTED]", "plugins": [ { "name": "expo", "config": { "id": "expo", "hooks": { "after": [ {} ] }, "endpoints": {} } }, { "name": "open-api", "config": { "id": "open-api", "endpoints": {} } }, { "name": "bearer", "config": { "id": "bearer", "hooks": { "before": [ {} ], "after": [ {} ] } } }, { "name": "email-otp", "config": { "id": "email-otp", "endpoints": {}, "hooks": { "after": [ {} ] }, "$ERROR_CODES": { "OTP_EXPIRED": "otp expired", "INVALID_OTP": "Invalid OTP", "INVALID_EMAIL": "Invalid email", "USER_NOT_FOUND": "User not found", "TOO_MANY_ATTEMPTS": "Too many attempts" }, "rateLimit": [ { "window": 60, "max": 3 }, { "window": 60, "max": 3 }, { "window": 60, "max": 3 }, { "window": 60, "max": 3 } ] } } ], "advanced": { "database": {} }, "trustedOrigins": [ "loopie://", "exp://", "http://localhost:8081" ], "session": { "expiresIn": 2592000, "updateAge": 86400, "cookieCache": { "enabled": true, "maxAge": 3600 } }, "socialProviders": { "google": { "enabled": true, "clientId": "[REDACTED]" } }, "user": { "modelName": "profiles", "fields": { "name": "displayName", "image": "avatarUrl", "emailVerified": "isVerified" }, "additionalFields": { "phone": { "type": "string", "required": false }, "username": { "type": "string", "required": true }, "region": { "type": "string", "required": false }, "countryCode": { "type": "string", "required": false }, "plan": { "type": "string", "required": true, "defaultValue": "free", "input": false }, "language": { "type": "string", "required": false }, "friendsCount": { "type": "number", "required": true, "defaultValue": 0 }, "photosShared": { "type": "number", "required": true, "defaultValue": 0 }, "dob": { "type": "date", "required": false }, "bio": { "type": "string", "required": false }, "location": { "type": "string", "required": false } }, "changeEmail": { "enabled": true } }, "hooks": {} } } } ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript import { expo } from "@better-auth/expo"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { bearer, createAuthMiddleware, emailOTP, openAPI, } from "better-auth/plugins"; import { eq } from "drizzle-orm"; import { db } from "../database/connection"; import { profiles } from "../database/schema/auth-schema"; import emailTemplateService from "../services/emailService/emailTemplateService"; import { generateUsernameFromEmail } from "../utils/generateUsernameFromEmail"; export const auth: any = betterAuth({ database: drizzleAdapter(db, { provider: "pg", }), emailAndPassword: { enabled: true, requireEmailVerification: false, minPasswordLength: 6, maxPasswordLength: 18, autoSignIn: true, }, secret: process.env.AUTH_SECRET || "auth-secret", plugins: [ expo(), openAPI(), bearer(), emailOTP({ async sendVerificationOTP({ email, otp, type }) { if (type === "sign-in") { emailTemplateService.sendVerificationOTP(email, otp); } else if (type === "email-verification") { // Send the OTP for email verification } else { emailTemplateService.sendResetPassword(email, otp); } }, }), ], advanced: { database: { generateId: () => crypto.randomUUID(), }, }, trustedOrigins: [ "loopie://", // Your app scheme "exp://", // Expo development "http://localhost:8081", // Expo development server ], session: { expiresIn: 60 * 60 * 24 * 30, // 30 days updateAge: 60 * 60 * 24, // 1 day cookieCache: { enabled: true, maxAge: 60 * 60, // 1 hour }, }, socialProviders: { google: { enabled: true, clientId: process.env.GOOGLE_CLIENT_ID || "", mapProfileToUser: (profile) => ({ displayName: profile.name, username: generateUsernameFromEmail(profile.email), avatarUrl: profile.picture, email: profile.email, isVerified: profile.email_verified, }), }, }, user: { modelName: "profiles", fields: { name: "displayName", image: "avatarUrl", emailVerified: "isVerified", }, additionalFields: { phone: { type: "string", required: false, }, username: { type: "string", required: true, defaultValue: () => { // This will be overridden in hooks for proper username generation return `tempuser_${Math.random().toString(36).substring(2, 10)}`; }, }, region: { type: "string", required: false, }, countryCode: { type: "string", required: false, }, plan: { type: "string", required: true, defaultValue: "free", input: false, }, language: { type: "string", required: false, }, friendsCount: { type: "number", required: true, defaultValue: 0, }, photosShared: { type: "number", required: true, defaultValue: 0, }, dob: { type: "date", required: false, }, bio: { type: "string", required: false, }, location: { type: "string", required: false, }, }, changeEmail: { enabled: true, }, }, }); ``` ### Additional context _No response_
GiteaMirror added the expo label 2026-03-13 09:40:36 -05:00
Author
Owner

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

Hi there! Thanks for bringing this to our attention.

Based on your description, it appears that the Expo plugin’s sign-out flow isn’t triggering the deletion of the session record in your database, whereas the Open API endpoint works as expected. This may be due to a difference in how the after hooks for sign-out are implemented in the Expo plugin compared to the Open API plugin. Here are some next steps you might try:

• Verify that the Expo plugin’s hook configuration is properly set up to trigger session deletion. It may help to compare the behavior with the Open API plugin’s hooks to see if any additional cleanup steps are missing.
• Check that your session configuration (including cookieCache settings) isn’t interfering with the deletion. Sometimes a persistent cookie cache can cause the session to appear still active even after sign-out.
• Ensure that the sign-out method being called by your Expo app actually forwards the sign-out request to Better Auth’s backend endpoint that is designed to handle session removal.

If these steps don’t resolve the issue, it could be a bug specific to the Expo plugin. In that case, please tag our team members @bekacru and @ping__ for further investigation.

tag @better-auth-agent if you would like an answer from the Agent

Could you let me know if these troubleshooting steps help or if you have any additional details/logs to share?

DiagramDiscordGitHub

Diagram Join Star

@better-auth-agent[bot] commented on GitHub (Nov 9, 2025): Hi there! Thanks for bringing this to our attention. Based on your description, it appears that the Expo plugin’s sign-out flow isn’t triggering the deletion of the session record in your database, whereas the Open API endpoint works as expected. This may be due to a difference in how the after hooks for sign-out are implemented in the Expo plugin compared to the Open API plugin. Here are some next steps you might try: • Verify that the Expo plugin’s hook configuration is properly set up to trigger session deletion. It may help to compare the behavior with the Open API plugin’s hooks to see if any additional cleanup steps are missing. • Check that your session configuration (including cookieCache settings) isn’t interfering with the deletion. Sometimes a persistent cookie cache can cause the session to appear still active even after sign-out. • Ensure that the sign-out method being called by your Expo app actually forwards the sign-out request to Better Auth’s backend endpoint that is designed to handle session removal. If these steps don’t resolve the issue, it could be a bug specific to the Expo plugin. In that case, please tag our team members @bekacru and @ping__ for further investigation. tag @better-auth-agent if you would like an answer from the Agent Could you let me know if these troubleshooting steps help or if you have any additional details/logs to share? <!-- 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 (Nov 9, 2025):

This is a known gap in the Expo plugin: it does not trigger session deletion in the database when you sign out, unlike the open API endpoint, which explicitly calls the session deletion logic (source). The Expo plugin mainly handles origin headers and OAuth flows, but does not wire up a sign-out endpoint that removes the session record (source).

To ensure sessions are deleted on sign out from Expo, the recommended workaround is to call the open API sign-out endpoint from your Expo client. This will trigger the same logic as a web sign out and remove the session from your database. If you need more control, you can add a custom hook or endpoint that explicitly deletes the session when signing out from Expo (source).

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Nov 9, 2025): <!-- Answer --> This is a known gap in the Expo plugin: it does not trigger session deletion in the database when you sign out, unlike the open API endpoint, which explicitly calls the session deletion logic ([source](https://github.com/better-auth/better-auth/blob/7067b58d358af0fb28ce4d272513ef21186c253c/packages/better-auth/src/api/routes/sign-out.ts#L34-L50)). The Expo plugin mainly handles origin headers and OAuth flows, but does not wire up a sign-out endpoint that removes the session record ([source](https://github.com/better-auth/better-auth/blob/7067b58d358af0fb28ce4d272513ef21186c253c/packages/expo/src/index.ts#L17-L121)). To ensure sessions are deleted on sign out from Expo, the recommended workaround is to call the open API sign-out endpoint from your Expo client. This will trigger the same logic as a web sign out and remove the session from your database. If you need more control, you can add a custom hook or endpoint that explicitly deletes the session when signing out from Expo ([source](https://github.com/better-auth/better-auth/issues/2096)). <!-- 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/18f28eaa-1fe1-434b-9c66-d4550a84304c?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/18f28eaa-1fe1-434b-9c66-d4550a84304c?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/18f28eaa-1fe1-434b-9c66-d4550a84304c?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/18f28eaa-1fe1-434b-9c66-d4550a84304c?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/18f28eaa-1fe1-434b-9c66-d4550a84304c?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/18f28eaa-1fe1-434b-9c66-d4550a84304c?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/18f28eaa-1fe1-434b-9c66-d4550a84304c?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&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/5868)
Author
Owner

@timbit123 commented on GitHub (Dec 17, 2025):

I'm having the same issue.
Also, the user-agent is not populated in the session when coming from mobile (expo)

@timbit123 commented on GitHub (Dec 17, 2025): I'm having the same issue. Also, the user-agent is not populated in the session when coming from mobile (expo)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2277