[Expo] SecureStore fails with "Invalid key" error - storage keys use colons #2154

Closed
opened 2026-03-13 09:30:46 -05:00 by GiteaMirror · 7 comments
Owner

Originally created by @ingokpp on GitHub (Oct 20, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

The @better-auth/expo plugin generates storage keys with colon separators (e.g., prefix:cookie), but expo-secure-store only accepts alphanumeric characters, ., -, and _. This
causes silent storage failures and session persistence issues.

Current vs. Expected behavior

Better Auth creates storage keys like:
myprefix:cookie
myprefix:session_data

But SecureStore throws:
Error: Invalid key provided to SecureStore.
Keys must not be empty and contain only alphanumeric characters, ".", "-", and "_".

import * as SecureStore from "expo-secure-store";
import { expoClient } from "@better-auth/expo/client";

expoClient({
  storagePrefix: "myapp",
  storage: SecureStore,
});

// After sign-in, Better Auth tries:
// SecureStore.setItemAsync("myapp:cookie", value)
// ❌ Fails: Invalid key error

🎯 Expected Behavior

Storage keys should be compatible with SecureStore's key requirements without requiring custom adapters.

What version of Better Auth are you using?

1.3.28

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.0.0: Wed Sep 17 21:41:26 PDT 2025; root:xnu-12377.1.9~141/RELEASE_ARM64_T6041",
    "release": "25.0.0",
    "cpuCount": 14,
    "cpuModel": "Apple M4 Pro",
    "totalMemory": "24.00 GB",
    "freeMemory": "0.20 GB"
  },
  "node": {
    "version": "v23.10.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.4.0"
  },
  "frameworks": [
    {
      "name": "react",
      "version": "19.0.0"
    }
  ],
  "databases": null,
  "betterAuth": {
    "version": "^1.3.28",
    "config": null
  }
}

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

Client

Auth config (if applicable)

import { expoClient } from "@better-auth/expo/client";
import { createAuthClient } from "better-auth/react";
import Constants from "expo-constants";
import { secureStoreAdapter } from "./secure-store-adapter";

const BASE_URL = process.env.EXPO_PUBLIC_AUTH_URL || "http://localhost:8081";

const scheme = Constants.expoConfig?.scheme as string;

export const authClient = createAuthClient({
  baseURL: BASE_URL,
  plugins: [
    expoClient({
      scheme: scheme,
      storagePrefix: scheme,
      cookiePrefix: "mycookieprefix", // MUST match backend's cookiePrefix
      storage: secureStoreAdapter, // REQUIRED: sync adapter with colon fix
    }),
  ],
});

Additional context

Replace colons with underscores in storage keys:

  const secureStoreAdapter = {
    getItem: (key: string) => {
      const normalized = key.replace(/:/g, '_');
      return SecureStore.getItemAsync(normalized);
    },
    setItem: (key: string, value: string) => {
      const normalized = key.replace(/:/g, '_');
      return SecureStore.setItemAsync(normalized, value);
    },
    removeItem: (key: string) => {
      const normalized = key.replace(/:/g, '_');
      return SecureStore.deleteItemAsync(normalized);
    },
  };
Originally created by @ingokpp on GitHub (Oct 20, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce The @better-auth/expo plugin generates storage keys with colon separators (e.g., prefix:cookie), but expo-secure-store only accepts alphanumeric characters, ., -, and _. This causes silent storage failures and session persistence issues. ### Current vs. Expected behavior Better Auth creates storage keys like: myprefix:cookie myprefix:session_data But SecureStore throws: Error: Invalid key provided to SecureStore. Keys must not be empty and contain only alphanumeric characters, ".", "-", and "_". ```typescript import * as SecureStore from "expo-secure-store"; import { expoClient } from "@better-auth/expo/client"; expoClient({ storagePrefix: "myapp", storage: SecureStore, }); // After sign-in, Better Auth tries: // SecureStore.setItemAsync("myapp:cookie", value) // ❌ Fails: Invalid key error ``` ## 🎯 Expected Behavior Storage keys should be compatible with SecureStore's key requirements without requiring custom adapters. ### What version of Better Auth are you using? 1.3.28 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.0.0: Wed Sep 17 21:41:26 PDT 2025; root:xnu-12377.1.9~141/RELEASE_ARM64_T6041", "release": "25.0.0", "cpuCount": 14, "cpuModel": "Apple M4 Pro", "totalMemory": "24.00 GB", "freeMemory": "0.20 GB" }, "node": { "version": "v23.10.0", "env": "development" }, "packageManager": { "name": "npm", "version": "11.4.0" }, "frameworks": [ { "name": "react", "version": "19.0.0" } ], "databases": null, "betterAuth": { "version": "^1.3.28", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript import { expoClient } from "@better-auth/expo/client"; import { createAuthClient } from "better-auth/react"; import Constants from "expo-constants"; import { secureStoreAdapter } from "./secure-store-adapter"; const BASE_URL = process.env.EXPO_PUBLIC_AUTH_URL || "http://localhost:8081"; const scheme = Constants.expoConfig?.scheme as string; export const authClient = createAuthClient({ baseURL: BASE_URL, plugins: [ expoClient({ scheme: scheme, storagePrefix: scheme, cookiePrefix: "mycookieprefix", // MUST match backend's cookiePrefix storage: secureStoreAdapter, // REQUIRED: sync adapter with colon fix }), ], }); ``` ### Additional context Replace colons with underscores in storage keys: ```typescript const secureStoreAdapter = { getItem: (key: string) => { const normalized = key.replace(/:/g, '_'); return SecureStore.getItemAsync(normalized); }, setItem: (key: string, value: string) => { const normalized = key.replace(/:/g, '_'); return SecureStore.setItemAsync(normalized, value); }, removeItem: (key: string) => { const normalized = key.replace(/:/g, '_'); return SecureStore.deleteItemAsync(normalized); }, }; ```
GiteaMirror added the expo label 2026-03-13 09:30:46 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Oct 20, 2025):

This issue with colons in storage keys is known and has been discussed by other users. In Better Auth v1.3.28, storage keys for the Expo client may use colons, which are incompatible with expo-secure-store and cause silent failures. The recommended solution is to use a custom adapter that replaces colons with underscores before passing keys to SecureStore, as you’ve already implemented. There are no reported side effects with this approach, but make sure all storage operations consistently normalize keys.

Recent updates to the @better-auth/expo plugin have changed the default key separator from colon to underscore, making storage keys like myapp_cookie and myapp_session_data compatible with SecureStore out of the box. If you upgrade to the latest version, you should no longer need a custom adapter for key normalization—just use the default configuration and ensure your storagePrefix does not include any unsupported characters. For more details, see the relevant pull request and code reference.

If you’re staying on v1.3.28, keep using your adapter. If you upgrade, you can remove it and use SecureStore directly.

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 (Oct 20, 2025): <!-- Answer --> This issue with colons in storage keys is known and has been discussed by other users. In Better Auth v1.3.28, storage keys for the Expo client may use colons, which are incompatible with expo-secure-store and cause silent failures. The recommended solution is to use a custom adapter that replaces colons with underscores before passing keys to SecureStore, as you’ve already implemented. There are no reported side effects with this approach, but make sure all storage operations consistently normalize keys. Recent updates to the @better-auth/expo plugin have changed the default key separator from colon to underscore, making storage keys like `myapp_cookie` and `myapp_session_data` compatible with SecureStore out of the box. If you upgrade to the latest version, you should no longer need a custom adapter for key normalization—just use the default configuration and ensure your `storagePrefix` does not include any unsupported characters. For more details, see the relevant [pull request](https://github.com/better-auth/better-auth/pull/5393) and [code reference](https://github.com/better-auth/better-auth/blob/e6088121726f55a88a4c97e941449356d03834a5/packages/expo/src/client.ts). If you’re staying on v1.3.28, keep using your adapter. If you upgrade, you can remove it and use SecureStore directly. <!-- 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/d8f26b01-19cd-4a53-9f7f-9ad4f12fbe12?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/d8f26b01-19cd-4a53-9f7f-9ad4f12fbe12?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/d8f26b01-19cd-4a53-9f7f-9ad4f12fbe12?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/d8f26b01-19cd-4a53-9f7f-9ad4f12fbe12?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/d8f26b01-19cd-4a53-9f7f-9ad4f12fbe12?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/d8f26b01-19cd-4a53-9f7f-9ad4f12fbe12?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/d8f26b01-19cd-4a53-9f7f-9ad4f12fbe12?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/5426)
Author
Owner

@ping-maxwell commented on GitHub (Oct 20, 2025):

Hey are you aware of what case specifically was adding the colon? Like what the cookie name was
(We'll still implement the normalization like you suggested, was just curious what case caused this)

@ping-maxwell commented on GitHub (Oct 20, 2025): Hey are you aware of what case specifically was adding the colon? Like what the cookie name was (We'll still implement the normalization like you suggested, was just curious what case caused this)
Author
Owner

@ingokpp commented on GitHub (Oct 20, 2025):

The cookie prefix is "elterngeldbuddy", so nothing special actually.

@ingokpp commented on GitHub (Oct 20, 2025): The cookie prefix is "elterngeldbuddy", so nothing special actually.
Author
Owner

@ping-maxwell commented on GitHub (Oct 20, 2025):

And that was followed by a colon? Did you catch what was after the colon?

@ping-maxwell commented on GitHub (Oct 20, 2025): And that was followed by a colon? Did you catch what was after the colon?
Author
Owner

@ingokpp commented on GitHub (Oct 20, 2025):

@ping-maxwell Yes! Better Auth was generating storage keys with this pattern:

${storagePrefix}:${keyName}

With storagePrefix: "elterngeldbuddy", it created:

Keys that failed:

  • elterngeldbuddy:cookie
  • elterngeldbuddy:session_data

SecureStore rejected these with:
Error: Invalid key provided to SecureStore.
Keys must not be empty and contain only alphanumeric characters, ".", "-", and "_".

After normalization (colons → underscores):

  • elterngeldbuddy_cookie
  • elterngeldbuddy_session_data

So it's not the cookie name that had the issue (those are like elterngeldbuddy.session_token), but the storage keys used to save them in SecureStore.

The colon separator appears to be how @better-auth/expo constructs storage keys internally, regardless of the prefix you choose.

@ingokpp commented on GitHub (Oct 20, 2025): @ping-maxwell Yes! Better Auth was generating storage keys with this pattern: ${storagePrefix}:${keyName} With storagePrefix: "elterngeldbuddy", it created: Keys that failed: - elterngeldbuddy:cookie - elterngeldbuddy:session_data SecureStore rejected these with: Error: Invalid key provided to SecureStore. Keys must not be empty and contain only alphanumeric characters, ".", "-", and "_". After normalization (colons → underscores): - elterngeldbuddy_cookie ✅ - elterngeldbuddy_session_data ✅ So it's not the cookie name that had the issue (those are like elterngeldbuddy.session_token), but the storage keys used to save them in SecureStore. The colon separator appears to be how @better-auth/expo constructs storage keys internally, regardless of the prefix you choose.
Author
Owner

@ingokpp commented on GitHub (Oct 20, 2025):

This is the custom adapter i build which does the normalization:

import * as SecureStore from 'expo-secure-store';

/**
 * SecureStore adapter for Better Auth
 *
 * SecureStore doesn't allow colons in key names, but Better Auth uses
 * keys like "prefix:keyName". This adapter replaces colons with underscores.
 *
 * Valid SecureStore key characters: alphanumeric, ".", "-", "_"
 *
 * NOTE: Better Auth expects synchronous storage, but SecureStore is async.
 * This adapter uses an in-memory cache to provide sync access.
 */

const normalizeKey = (key: string): string => {
  // Replace colons with underscores to make keys compatible with SecureStore
  const normalized = key.replace(/:/g, '_');
  return normalized;
};

// In-memory cache for sync access
// Security Note: This cache stores tokens in plain memory for performance.
// The cache is:
// - Isolated to app's memory space (other apps cannot access)
// - Backed up to encrypted SecureStore
// - Cleared on logout (via removeItem)
// This is a recommended pattern for React Native auth tokens.
const cache = new Map<string, string | null>();

// Initialize cache from SecureStore on startup
const STORAGE_PREFIX = 'elterngeldbuddy'; // Must match storagePrefix in auth-client
const COOKIE_KEY = `${STORAGE_PREFIX}_cookie`;
const SESSION_DATA_KEY = `${STORAGE_PREFIX}_session_data`;

// Pre-load critical keys into cache
(async () => {
  try {
    const cookie = await SecureStore.getItemAsync(COOKIE_KEY);
    const sessionData = await SecureStore.getItemAsync(SESSION_DATA_KEY);

    if (cookie) {
      cache.set(COOKIE_KEY, cookie);
    }
    if (sessionData) {
      cache.set(SESSION_DATA_KEY, sessionData);
    }
  } catch (error) {
    console.error('[SecureStore Adapter] Error pre-loading cache:', error);
  }
})();

export const secureStoreAdapter = {
  // Sync method that reads from cache (Better Auth expects sync)
  getItem: (key: string): string | null => {
    const normalizedKey = normalizeKey(key);
    return cache.get(normalizedKey) ?? null;
  },

  // Sync method that writes to cache AND async to SecureStore
  setItem: (key: string, value: string): void => {
    const normalizedKey = normalizeKey(key);

    // Update cache immediately (sync)
    cache.set(normalizedKey, value);

    // Persist to SecureStore in background (async)
    SecureStore.setItemAsync(normalizedKey, value).catch(error => {
      console.error('[SecureStore Adapter] Error persisting to SecureStore:', error);
    });
  },

  // Sync method that removes from cache AND async from SecureStore
  removeItem: (key: string): void => {
    const normalizedKey = normalizeKey(key);

    // Remove from cache immediately (sync)
    cache.delete(normalizedKey);

    // Remove from SecureStore in background (async)
    SecureStore.deleteItemAsync(normalizedKey).catch(error => {
      console.error('[SecureStore Adapter] Error removing from SecureStore:', error);
    });
  },
};

/**
 * Security utility: Clear all cached auth data from memory
 * Call this on logout or when auth is no longer needed
 */
export const clearAuthCache = () => {
  cache.clear();
};

@ingokpp commented on GitHub (Oct 20, 2025): This is the custom adapter i build which does the normalization: ```typescript import * as SecureStore from 'expo-secure-store'; /** * SecureStore adapter for Better Auth * * SecureStore doesn't allow colons in key names, but Better Auth uses * keys like "prefix:keyName". This adapter replaces colons with underscores. * * Valid SecureStore key characters: alphanumeric, ".", "-", "_" * * NOTE: Better Auth expects synchronous storage, but SecureStore is async. * This adapter uses an in-memory cache to provide sync access. */ const normalizeKey = (key: string): string => { // Replace colons with underscores to make keys compatible with SecureStore const normalized = key.replace(/:/g, '_'); return normalized; }; // In-memory cache for sync access // Security Note: This cache stores tokens in plain memory for performance. // The cache is: // - Isolated to app's memory space (other apps cannot access) // - Backed up to encrypted SecureStore // - Cleared on logout (via removeItem) // This is a recommended pattern for React Native auth tokens. const cache = new Map<string, string | null>(); // Initialize cache from SecureStore on startup const STORAGE_PREFIX = 'elterngeldbuddy'; // Must match storagePrefix in auth-client const COOKIE_KEY = `${STORAGE_PREFIX}_cookie`; const SESSION_DATA_KEY = `${STORAGE_PREFIX}_session_data`; // Pre-load critical keys into cache (async () => { try { const cookie = await SecureStore.getItemAsync(COOKIE_KEY); const sessionData = await SecureStore.getItemAsync(SESSION_DATA_KEY); if (cookie) { cache.set(COOKIE_KEY, cookie); } if (sessionData) { cache.set(SESSION_DATA_KEY, sessionData); } } catch (error) { console.error('[SecureStore Adapter] Error pre-loading cache:', error); } })(); export const secureStoreAdapter = { // Sync method that reads from cache (Better Auth expects sync) getItem: (key: string): string | null => { const normalizedKey = normalizeKey(key); return cache.get(normalizedKey) ?? null; }, // Sync method that writes to cache AND async to SecureStore setItem: (key: string, value: string): void => { const normalizedKey = normalizeKey(key); // Update cache immediately (sync) cache.set(normalizedKey, value); // Persist to SecureStore in background (async) SecureStore.setItemAsync(normalizedKey, value).catch(error => { console.error('[SecureStore Adapter] Error persisting to SecureStore:', error); }); }, // Sync method that removes from cache AND async from SecureStore removeItem: (key: string): void => { const normalizedKey = normalizeKey(key); // Remove from cache immediately (sync) cache.delete(normalizedKey); // Remove from SecureStore in background (async) SecureStore.deleteItemAsync(normalizedKey).catch(error => { console.error('[SecureStore Adapter] Error removing from SecureStore:', error); }); }, }; /** * Security utility: Clear all cached auth data from memory * Call this on logout or when auth is no longer needed */ export const clearAuthCache = () => { cache.clear(); }; ```
Author
Owner

@ping-maxwell commented on GitHub (Oct 20, 2025):

Okay got it, thanks for the detailed response!

@ping-maxwell commented on GitHub (Oct 20, 2025): Okay got it, thanks for the detailed response!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2154