A focused, zero-runtime-dependency vault for refresh tokens in Capacitor 8 applications.
Where do you keep a refresh token in a Capacitor app? Not in localStorage. This is secure
storage scoped to one job: it keeps the token in the safest store each platform offers: iOS
Keychain, Android Keystore, or sessionStorage on the web. The same five-method API has zero
runtime dependencies.
Quick start · Integration patterns · AI prompts · Compatibility · What each platform does · Threat model · FAQ · Design notes · Support ☕
await TokenVault.setToken({value: refreshToken});
const {value} = await TokenVault.getToken(); // string | null
await TokenVault.clear(); // logoutFree and open source, maintained in spare time — a donation helps fund security updates, fixes,
documentation, and new releases. 🙏 Sponsor AfanasievN on GitHub,
or donate in TON:
Storing a refresh token is the most common Capacitor security question, and the usual answers do not hold up:
| Common choice | What goes wrong |
|---|---|
localStorage / sessionStorage in a native app |
a plaintext file inside the app sandbox - readable on a rooted or jailbroken device |
@capacitor/preferences |
plain UserDefaults / SharedPreferences; it is not encrypted storage and does not claim to be |
| generic secure-storage plugins | usually no control over Keychain accessibility or iCloud sync, so tokens can travel into backups and onto other devices |
androidx.security EncryptedSharedPreferences |
deprecated since security-crypto:1.1.0-alpha07 (main-thread performance, OEM keyset corruption) |
This plugin does one thing with a fixed, documented security posture that a caller cannot accidentally weaken.
npm install capacitor-token-vault
npx cap syncTwo install steps a plugin cannot do for you:
1. Keep the native build lean. Capacitor links every plugin it finds; pin the allowlist:
// capacitor.config.ts
const config: CapacitorConfig = {
includePlugins: ["capacitor-token-vault"],
};2. Exclude the Android store from cloud backups, so the encrypted blob does not travel to other devices (the key never leaves the device, so it could only fail to decrypt - but shipping it is pointless):
<!-- android/app/src/main/AndroidManifest.xml -->
<application android:allowBackup="false" ...>Want backups on? Use a dataExtractionRules exclusion for token_vault.xml instead. iOS needs
nothing - WhenUnlockedThisDeviceOnly items are never included in a backup.
your code
| setToken / getToken / removeToken / clear / getCapabilities
▼
registerPlugin("TokenVault") picks the implementation, 13 lines
│
┌─────┴───────────────┬────────────────────────────┐
▼ ▼ ▼
iOS bridge Android bridge web
TokenVaultPlugin TokenVaultPlugin TokenVaultWeb
│ argument │ argument │
│ plumbing only │ plumbing only │
▼ ▼ ▼
TokenVault.swift TokenVault.kt sessionStorage
SecItemAdd/Copy Keystore key + AES-GCM (memory fallback)
│ │ │
▼ ▼ ▼
Keychain SharedPreferences browser storage
WhenUnlocked (ciphertext only) secure: false
ThisDeviceOnly hardware status queried
The bridge files are argument plumbing only, so the platform code is unit-testable without Capacitor.
Your app talks to one API and never branches on the platform - it branches on getCapabilities().
The value is not code volume. It is the storage attributes, crypto parameters, failure behavior, and tests. See docs/DESIGN.md.
Three shapes, depending on how your app is built - direct in an auth service, behind your own port/DI, or wrapped in an HTTP interceptor with single-flight refresh. Full working examples: docs/INTEGRATION.md.
Using an AI agent to wire it up? docs/AI.md has copy-paste prompts that adapt to your
architecture and state the security rules an agent tends to get wrong (persisting the access token,
logging users out on a network blip, branching on the platform instead of capabilities). Agents that
read URLs can start from llms.txt.
| Supported | |
|---|---|
| Capacitor | 8.x (@capacitor/core is a peer dependency, >=8.0.0) |
| iOS | 15.0+ · SPM (a podspec is included for CocoaPods projects) |
| Android | API 24+ (Android 7) · compileSdk 36 · JDK 21 |
| Web | any browser with sessionStorage; degrades to memory without it |
| Node (tooling) | 20, 22, 24 - tested in CI |
| Module formats | ESM and CommonJS (import and require both work) |
Capacitor 6/7 are not supported: the plugin uses the CAPBridgedPlugin registration introduced for
Capacitor 6+ and is only tested against 8. If you need an older major, open an issue - the native
code itself has no version-specific dependencies.
import {TokenVault} from "capacitor-token-vault";
// write / read / delete the default slot ("refresh")
await TokenVault.setToken({value: refreshToken});
const {value} = await TokenVault.getToken(); // null when empty - not an error
await TokenVault.removeToken();
// more than one secret? named slots
await TokenVault.setToken({value: deviceToken, name: "device"});
// logout: every slot this plugin owns
await TokenVault.clear();
// branch on what you actually got, not on the platform name
const caps = await TokenVault.getCapabilities();
if (!caps.persistent) {
// web tab or private mode: do not promise "stay signed in"
}Slot names match ^[a-zA-Z0-9._-]{1,64}$. Rejections carry code:
UNAVAILABLE | INVALID_ARGUMENT | STORAGE_FAILURE - and never contain the token value.
| Method | Result |
|---|---|
getCapabilities() |
{backend, secure, persistent, hardwareBacked} |
setToken({value, name?}) |
writes or overwrites a slot |
getToken({name?}) |
{value: string | null} |
removeToken({name?}) |
idempotent delete |
clear() |
removes every slot owned by this plugin |
The API is small, so a migration is a one-time copy on first launch. Read with the old plugin, write with this one, delete the old value:
import {Preferences} from "@capacitor/preferences"; // or your current plugin
import {TokenVault} from "capacitor-token-vault";
async function migrateToken(): Promise<void> {
if ((await TokenVault.getToken()).value !== null) return; // already migrated
const {value} = await Preferences.get({key: "refreshToken"});
if (!value) return;
await TokenVault.setToken({value});
await Preferences.remove({key: "refreshToken"}); // stop leaving a plaintext copy
}| Coming from | Notes |
|---|---|
@capacitor/preferences, localStorage |
the old value is plaintext - remove it after copying, as above |
capacitor-secure-storage-plugin |
get/set/remove map 1:1; its iOS items live under a different Keychain service, so read them with that plugin during the migration window |
@aparajita/capacitor-secure-storage |
same shape; if you only stored a token, you can drop that dependency (and the two Capacitor plugins it pulls in) afterwards |
Keep the migration for a release or two, then delete it - a user who skips versions still passes through it as long as the code is there.
| Platform | Where the token goes | Fixed parameters |
|---|---|---|
| iOS 15+ | Keychain, kSecClassGenericPassword, service <bundleId>.token-vault |
kSecAttrAccessibleWhenUnlockedThisDeviceOnly (also what keeps it out of backups), kSecAttrSynchronizable = false |
| Android 7+ (API 24) | AES-256-GCM ciphertext in SharedPreferences("token_vault", MODE_PRIVATE) |
key capacitor.token-vault.v1 generated in AndroidKeyStore, GCM, no padding, 256-bit, randomized IV per write, no user-auth requirement |
| Web / PWA | sessionStorage under token-vault., in-memory when storage is blocked |
reports secure: false; localStorage is never used |
Why these choices, in short - the long version is in docs/DESIGN.md:
WhenUnlockedThisDeviceOnlyis the only Keychain class that both requires an unlocked device and is excluded from backup/restore onto another device. Cost: background code cannot read the token while the device is locked - fine, since a refresh follows app use.- Keystore directly instead of
EncryptedSharedPreferences- see the table above; ~60 lines with no library lifecycle risk. - A versioned key alias so a future parameter change becomes a new alias plus a documented migration, not silent decryption failures on real installs.
sessionStorageon the web rather than anUnavailableerror, so consumers get working behavior everywhere and the ones who care readcapabilities.secure.- A corrupt or undecryptable slot reads as "absent" on every platform: broken storage must never lock a user out of signing in again.
- A token written by a previous installation is not returned. iOS keeps Keychain items when an app
is deleted, so a fresh install can find someone else's token on a resold or shared device. The
plugin writes a per-slot marker into
UserDefaults, which is removed with the app, and treats a token without its matching marker as absent, clearing it. Android needs nothing: its store goes away with the app.
Usually no. Keep the access token in memory and only persist the refresh token: a short-lived token in memory cannot be stolen from disk at all. Named slots exist if you genuinely need a second secret.
Yes - setToken({value: JSON.stringify(pair)}). The plugin deliberately does not parse your payload;
it stores an opaque string.
Because no browser has a secure store. The plugin uses sessionStorage (tab-scoped, the smallest
window) and never localStorage, and tells you the truth so you can decide what to promise the user.
Nothing is inherited: see the note above. Plan for the user to sign in again.
Not in v1 - it changes the failure surface (enrollment invalidation, cancel flows) and belongs to a
session-policy layer. The design leaves room for an opt-in requireUserPresence without changing the
stored format; open an issue if you need it.
No. It is asked of the Keystore per key, so emulators and devices with a software Keystore report
false. Branch on the value rather than assuming it.
Yes - the package ships ESM and CommonJS, and CI loads both.
No. The Android manifest is empty and iOS needs no entitlement (no Keychain sharing, no iCloud).
Protects against: another app or a shell on a rooted/jailbroken device reading the token off disk; the token surviving in a device backup and being restored elsewhere; iCloud Keychain sync carrying it to another device.
Does not protect against: code execution inside your app - XSS in the WebView or a malicious
dependency can call getToken() exactly like your code does. Strict CSP and supply-chain hygiene are
the controls there; storage choice only limits theft at rest. Full statement: SECURITY.md.
No biometric gate in v1: kSecAccessControl / setUserAuthenticationRequired change the failure
surface (enrollment invalidation, cancel flows) and belong to a session-policy feature rather than to
storage. The design leaves room for an opt-in requireUserPresence without changing the stored format.
npm ls --omit=dev --all prints an empty tree, and CI asserts it on
every push. Concretely: @capacitor/core is a peer dependency (declaring it as a dependency is
what pulls a second Capacitor into a consumer's tree); Android compiles against platform Keystore
APIs only; iOS depends on Capacitor alone; the build is plain tsc, so there is no bundler chain
either. For a package that holds credentials, every transitive dependency is someone else's write
access to your token store.
npm install
npm run verify # typecheck + web unit tests + dual (ESM + CJS) buildiOS ownership tests run directly in the simulator package. Tests that hit the real Keychain need an app-hosted test target with Keychain entitlements; a bare Swift Package test process cannot prove those guarantees:
xcodebuild test -scheme CapacitorTokenVault \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-only-testing:TokenVaultPluginTests/InstallationOwnershipTestsThe Android library build, instrumented-test compilation, and lint run from this repository:
./android/gradlew -p android assembleDebug assembleDebugAndroidTest lintDebugExecuting the instrumented tests still needs a device or emulator. AndroidKeyStore has no JVM
implementation, so a Robolectric test would not prove the part that matters.
Status, honestly: CI verifies TypeScript, web behavior, both package module formats, the npm archive, the Android library build/test compilation/lint, the iOS library build, and the pure iOS ownership tests. Executing the Keychain and Android Keystore integration suites still needs properly hosted native test applications.
The native suites are the security tests: on iOS they assert the accessibility and sync attributes; on Android that a second instance decrypts what the first wrote, that ciphertext differs per write (randomized IV), and that the plaintext never appears in the stored value.
Contributions welcome - CONTRIBUTING.md explains the one rule that shapes every review: this plugin stays small.
MIT © AfanasievN
