sghimire/mobile-secure-storage

Self-contained hardware-backed secure storage (Android Keystore / iOS Keychain) for NativePHP Mobile — own facade, validated native implementation, and JS bindings, with no dependency on the paid nativephp/mobile-secure-storage plugin.

Maintainers

Package info

github.com/SandipGhimire/NativePHP-MobileSecureStorage

Type:nativephp-plugin

pkg:composer/sghimire/mobile-secure-storage

Transparency log

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.1 2026-07-26 19:22 UTC

This package is auto-updated.

Last update: 2026-07-26 19:25:45 UTC


README

Hardware-backed, encrypted key-value storage for NativePHP Mobile apps — Android Keystore-backed EncryptedSharedPreferences on Android, Keychain on iOS.

This package is a free, self-contained alternative to the paid nativephp/mobile-secure-storage plugin. It ships its own Laravel facade, a validated implementation, JS/TypeScript bindings, and the native Kotlin/Swift implementation — with no dependency on the paid plugin.

Features

  • Simple set / get / has / delete API for storing small secrets (auth tokens, PINs, flags) securely on-device.
  • Backed by the platform's real secure storage — Android Keystore and iOS Keychain — not plain preferences/UserDefaults.
  • Key and value validated both in PHP/JS and natively before anything touches the keystore/keychain.
  • Fully synchronous from the caller's point of view — no events to listen for.
  • Works from PHP (Blade/Livewire) and from JavaScript (Vue, React, Inertia, or plain JS).

Requirements

Installation

composer require sghimire/mobile-secure-storage

Laravel's package auto-discovery registers SecureStorageServiceProvider for you. Then register the plugin with NativePHP:

php artisan native:plugin:register

This wires up the plugin's nativephp.json manifest (bridge functions) into your native build. Rebuild/reinstall the native shell afterwards (php artisan native:install or native:run) so the bridge functions are picked up. No special permissions are required on either platform.

How It Works (Under the Hood)

Unlike the biometrics and scanner plugins, secure storage is a synchronous request/response bridge call — set(), get(), and delete() all resolve immediately with their result. There's no native-event listener involved and nothing to subscribe to.

JS calls reach the native side via fetch('/_native/api/call', { method: 'Vault.Set' | 'Vault.Get' | 'Vault.Delete', params }); PHP calls the same bridge directly through the global nativephp_call() function. Either way, the bridge router matches the method name to SecureStorageFunctions.Set / .Get / .Delete (Kotlin on Android, Swift on iOS), which reads or writes the platform's secure store and returns the result in that same call — no second round-trip, no injected event script, unlike the async flow in the biometrics/scanner plugins.

Every call is validated twice:

  1. In PHP/JS, before the bridge is even called (fast fail, no round-trip).
  2. Natively, on the Android/iOS side, before touching the Keystore/Keychain (defense in depth).

Limits

Limit
Key length 255 characters, must be non-empty (not just whitespace)
Value size 8192 bytes (UTF-8 encoded)

Passing null as a value to set() is always allowed regardless of size limits — it deletes the key.

PHP Usage

The SecureStorage facade

use Sandip\SecureStorage\Native\Facades\SecureStorage;

// Store a value
SecureStorage::set('auth_token', 'eyJhbGciOi...');

// Retrieve it
$token = SecureStorage::get('auth_token'); // string, or null if not set

// Check existence
if (SecureStorage::has('auth_token')) {
    // ...
}

// Remove it
SecureStorage::delete('auth_token');

// set(key, null) also deletes
SecureStorage::set('auth_token', null);

Method reference

Method Signature Description
set (string $key, ?string $value): bool Stores $value under $key, or deletes the key if $value is null. Returns true on success.
get (string $key): ?string Returns the stored value, or null if the key doesn't exist.
has (string $key): bool Shorthand for get($key) !== null.
delete (string $key): bool Removes the key. Returns true on success.

Validation

set(), get(), and delete() all throw InvalidArgumentException for an invalid key or value before calling the native bridge:

use Sandip\SecureStorage\Native\Facades\SecureStorage;

SecureStorage::set('', 'value');                       // throws: key must not be empty
SecureStorage::set('   ', 'value');                     // throws: key must not be empty (whitespace-only)
SecureStorage::set(str_repeat('a', 256), 'value');      // throws: key exceeds 255 characters
SecureStorage::set('key', str_repeat('a', 8193));       // throws: value exceeds 8192 bytes
SecureStorage::set('key', null);                        // OK — null bypasses the value size check (it's a delete)

A practical example

use Sandip\SecureStorage\Native\Facades\SecureStorage;

class AuthTokenStore
{
    protected string $key = 'auth_token';

    public function remember(string $token): void
    {
        SecureStorage::set($this->key, $token);
    }

    public function token(): ?string
    {
        return SecureStorage::get($this->key);
    }

    public function forget(): void
    {
        SecureStorage::delete($this->key);
    }
}

JavaScript Usage

Importing

This package doesn't publish a #nativephp import alias (that's reserved for NativePHP's first-party plugins). Import the file directly — either from the vendor path, or copy it into your own resources/js/ and import it from there:

import { SecureStorage } from '../../vendor/sghimire/mobile-secure-storage/resources/js/secure-storage.js';

Full TypeScript types are included in secure-storage.d.ts alongside it.

Basic usage

Every method is async and returns a Promise:

import { SecureStorage } from '../../vendor/sghimire/mobile-secure-storage/resources/js/secure-storage.js';

// Store a value
await SecureStorage.set('auth_token', 'eyJhbGciOi...');

// Retrieve it — { value: string } shape
const { value } = await SecureStorage.get('auth_token');

// Check existence — returns a plain boolean
const exists = await SecureStorage.has('auth_token');

// Remove it
await SecureStorage.delete('auth_token');

// set(key, null) also deletes
await SecureStorage.set('auth_token', null);

Invalid keys/values throw synchronously-rejected errors before any network call is made:

try {
  await SecureStorage.set('', 'value');
} catch (error) {
  console.error(error.message); // "SecureStorage key must be a non-empty string."
}

Vue 3 example

<script setup>
import { ref } from 'vue';
import { SecureStorage } from '../../vendor/sghimire/mobile-secure-storage/resources/js/secure-storage.js';

const token = ref(null);

async function saveToken(value) {
  await SecureStorage.set('auth_token', value);
  token.value = value;
}

async function loadToken() {
  const { value } = await SecureStorage.get('auth_token');
  token.value = value;
}

async function clearToken() {
  await SecureStorage.delete('auth_token');
  token.value = null;
}
</script>

<template>
  <button @click="loadToken">Load token</button>
  <button @click="clearToken">Clear token</button>
  <p v-if="token">Token: {{ token }}</p>
</template>

React example

import { useState, useCallback } from 'react';
import { SecureStorage } from '../../vendor/sghimire/mobile-secure-storage/resources/js/secure-storage.js';

export function TokenManager() {
  const [token, setToken] = useState(null);

  const loadToken = useCallback(async () => {
    const { value } = await SecureStorage.get('auth_token');
    setToken(value);
  }, []);

  const clearToken = useCallback(async () => {
    await SecureStorage.delete('auth_token');
    setToken(null);
  }, []);

  return (
    <>
      <button onClick={loadToken}>Load token</button>
      <button onClick={clearToken}>Clear token</button>
      {token && <p>Token: {token}</p>}
    </>
  );
}

JS API reference

Method Signature Description
SecureStorage.set (key: string, value?: string | null) => Promise<{ success: true }> Stores value under key, or deletes the key if value is null/omitted.
SecureStorage.get (key: string) => Promise<{ value: string }> Resolves with the stored value (empty string if not found — use .has() to check existence explicitly).
SecureStorage.has (key: string) => Promise<boolean> Resolves true if a non-empty value exists for key.
SecureStorage.delete (key: string) => Promise<{ success: true }> Removes key.

Error codes

If validation fails on the native side (a defense-in-depth check behind the PHP/JS validation above), the bridge call rejects/returns an error with one of these codes:

Code Meaning
KEY_REQUIRED Key was empty or missing.
KEY_TOO_LONG Key exceeded 255 characters.
VALUE_TOO_LARGE Value exceeded 8192 bytes.
WRITE_FAILED (iOS) Keychain write/update failed.
DELETE_FAILED (iOS) Keychain delete failed.

In JS, the thrown Error carries this in error.code.

Implementation Guide: Persisting an Auth Token After Login

A typical mobile pattern: your Laravel API returns an auth token on login, you store it in the platform Keystore/Keychain instead of localStorage, then attach it to every subsequent API call.

1. Backend: issue a token on login

// routes/api.php
Route::post('/login', LoginController::class);
// app/Http/Controllers/LoginController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class LoginController extends Controller
{
    public function __invoke(Request $request)
    {
        if (! auth()->attempt($request->only('email', 'password'))) {
            abort(401);
        }

        return response()->json([
            'token' => auth()->user()->createToken('mobile')->plainTextToken,
        ]);
    }
}

2a. Livewire: store and reuse the token server-side

If your Livewire component makes the login request itself (e.g. via Http::post(...)), store the resulting token right after:

use Illuminate\Support\Facades\Http;
use Sandip\SecureStorage\Native\Facades\SecureStorage;

class LoginForm extends \Livewire\Component
{
    public string $email = '';
    public string $password = '';

    public function login(): void
    {
        $response = Http::post(config('app.url').'/api/login', [
            'email' => $this->email,
            'password' => $this->password,
        ]);

        SecureStorage::set('auth_token', $response->json('token'));

        $this->redirect(route('dashboard'));
    }

    public function logout(): void
    {
        SecureStorage::delete('auth_token');
        $this->redirect(route('login'));
    }
}

2b. Vue/React: store the token and attach it via an axios interceptor

// resources/js/auth.js
import axios from 'axios';
import { SecureStorage } from '../../vendor/sghimire/mobile-secure-storage/resources/js/secure-storage.js';

export async function login(email, password) {
  const { data } = await axios.post('/api/login', { email, password });
  await SecureStorage.set('auth_token', data.token);
}

export async function logout() {
  await SecureStorage.delete('auth_token');
}

// Attach the stored token to every outgoing request
axios.interceptors.request.use(async (config) => {
  const { value: token } = await SecureStorage.get('auth_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});
<!-- resources/js/Pages/Login.vue -->
<script setup>
import { ref } from 'vue';
import { login } from '../auth.js';

const email = ref('');
const password = ref('');
const error = ref(null);

async function submit() {
  error.value = null;
  try {
    await login(email.value, password.value);
    window.location.href = '/dashboard';
  } catch (e) {
    error.value = 'Invalid credentials';
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="email" type="email" placeholder="Email" />
    <input v-model="password" type="password" placeholder="Password" />
    <button type="submit">Log in</button>
    <p v-if="error">{{ error }}</p>
  </form>
</template>
// resources/js/Pages/Login.jsx
import { useState } from 'react';
import { login } from '../auth.js';

export function Login() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState(null);

  async function submit(e) {
    e.preventDefault();
    setError(null);
    try {
      await login(email, password);
      window.location.href = '/dashboard';
    } catch {
      setError('Invalid credentials');
    }
  }

  return (
    <form onSubmit={submit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" placeholder="Email" />
      <input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="Password" />
      <button type="submit">Log in</button>
      {error && <p>{error}</p>}
    </form>
  );
}

Because SecureStorage is synchronous request/response with no events, the interceptor pattern above is all you need — no listener setup, no cleanup on unmount. Remember get() resolves { value: '' } (empty string, not null) when the key doesn't exist, so guard with if (token) as shown, or call SecureStorage.has() first if you need an explicit boolean.

Platform notes

Android iOS
Min OS version API 23 15.0
Storage backend EncryptedSharedPreferences (AndroidX Security Crypto, Keystore-backed) Keychain Services
Permissions None required None required
Native implementation resources/android/SecureStorageFunctions.kt resources/ios/SecureStorageFunctions.swift

Both are configured automatically by nativephp.json — you don't need to edit native project files by hand.

Testing

composer install
composer test

Outside of a compiled native shell, SecureStorage::get() returns null and set()/delete() return false (there's no bridge to call) — this is expected and is exactly what the test suite asserts.

License

MIT