RORK LABEN
PLANS — 公式のプラン表は Free が Design mode のみ、Rork Pro が月20ドルで100クレジット、Rork Max が200ドルからという構成ですEXPOGO — Expo Go で開発中のプロジェクトを動かすには、CLI とアプリの両方で同じアカウントにログインすることが必要になりました。現時点では iOS 版のみです11/01 — Google Play の target API 36 要件は、延長を申請していても11月1日で終わります。残り46日ですAPKENV — JS のソースを変えずに EXPO_PUBLIC_ の値だけ変えると、Gradle が最新と判定して前回のバンドルを使い回す報告があります。本番の向き先がずれる形ですNEW — 思ったより早く残高が減る前に、Build クレジットと Cloud クレジットを分けて見る。2本立ての残高を切り分けた記録ですPLIST — lsapplicationqueriesschemes は Apple の現行ドキュメントでは404になります。正本はアーカイブ側の Launch Services Keys ですPLANS — 公式のプラン表は Free が Design mode のみ、Rork Pro が月20ドルで100クレジット、Rork Max が200ドルからという構成ですEXPOGO — Expo Go で開発中のプロジェクトを動かすには、CLI とアプリの両方で同じアカウントにログインすることが必要になりました。現時点では iOS 版のみです11/01 — Google Play の target API 36 要件は、延長を申請していても11月1日で終わります。残り46日ですAPKENV — JS のソースを変えずに EXPO_PUBLIC_ の値だけ変えると、Gradle が最新と判定して前回のバンドルを使い回す報告があります。本番の向き先がずれる形ですNEW — 思ったより早く残高が減る前に、Build クレジットと Cloud クレジットを分けて見る。2本立ての残高を切り分けた記録ですPLIST — lsapplicationqueriesschemes は Apple の現行ドキュメントでは404になります。正本はアーカイブ側の Launch Services Keys です
記事一覧/アプリ開発
アプリ開発/2026-09-16中級

消したはずのトークンが再起動で戻る — SecureStore の削除失敗を検知する設計

サインアウト直後に getItemAsync が null を返しても、Android のディスクには値が残っていることがあります。削除の成否を読み出しで確かめていた実装を、戻り値で判断する形へ書き直した記録です。

expo-secure-store3Expo208Android50サインアウトRork566

サインアウトを押したあと、ログイン画面に戻るところまでは手元で確かめておりました。おかしいと思ったのは、その端末を再起動してアプリを開き直したときです。ログイン済みの画面が、何ごともなかったように出てきました。

コードは素直に書いてあったはずでした。SecureStore.deleteItemAsync を呼び、続けて getItemAsyncnull を返すことを確認してから、サインイン画面へ遷移する——その順番です。確認は通っておりました。それでも値は残っていたのです。

原因は仕様の穴ではなく、私の確かめ方にありました。同じ取り違えは、トークンや API キーの後始末を書いている方なら、どなたでも踏みうるかたちをしています。

サインアウトの直後に null が返っても、消えたとは限りません

Expo の公式リファレンスによれば、deleteItemAsync は「値を削除できなかった場合に reject する Promise」を返します(SecureStore — Expo Documentation)。ここまでは素直です。

ところが Android では、その reject に至る前に、プロセス内に持っているマップから先に鍵が外れていることがあります。読み出しはそのマップを見て「あるか/ないか」を判定しますので、同じプロセスが生きているあいだは null が返り続けます。ディスク上のファイルには、暗号化された値がそのまま残ったままです。

アプリを再起動すると、マップはディスクから読み直されます。私の端末で値が戻って見えたのは、そのためでした。

画面で見えること実際に起きていること気づける場面
削除が成功し、サインアウトできたディスクからも消えている
削除の例外を握りつぶしたが、読み出しは nullディスクには残っている再起動、または別プロセスからの読み出し
削除が例外を投げ、その場で気づいたディスクには残っているその場

二段目が厄介です。画面の上では一段目とまったく同じに見えます。

Android の SecureStore が「無い」と答える根拠

Android では、SecureStore の値は SharedPreferences に置かれ、Android Keystore の鍵で暗号化されます。iOS ではキーチェーンに kSecClassGenericPassword として入ります。同じ API に見えても、下にある器はまったく別のものです。

この挙動は Expo 側の Issue として報告されております。削除がディスクへ届かずに reject した時点で、メモリ上のマップはすでに空になっており、読み出しがそのマップに問い合わせる限り「無い」と答え続ける——という指摘です(expo/expo Issue #49934)。プロセスを作り直せば値は戻ります。何も上書きしていないのですから、当然といえば当然でした。

null が「削除できた」以外の意味を持つ経路は、もう一つあります。公式リファレンスは、requireAuthentication: true で保存した値について、生体情報の登録内容が変わると鍵が無効化され、その後は読み出せなくなると書いています。無効化された鍵に対する getItemAsyncnull を返します。指紋を一つ追加しただけのユーザーが、削除した覚えのない値を失う——これも読み出しでは区別がつきません。

読み出しの null は、少なくとも三つの状態をひとまとめにしています。値がそもそも無い場合と、削除に失敗してマップだけが空になっている場合と、鍵が無効化された場合です。この三つを一つの if で引き受けさせていたことが、そもそもの無理でした。

もう一つ、方向の違う落とし穴が iOS にあります。公式ドキュメントは、iOS のキーチェーンに保存した値は同じ bundle ID で再インストールすると残ることがあると明記しています。Android は再インストールで消えます。「消えたかどうか」の感覚が、プラットフォームごとに逆向きにずれるのです。

削除の結果を握りつぶさない — 最小の書き直し

私が書いていたのは、こういうコードでした。

// Before: 失敗しても「読めなければよい」と考えていました
async function signOut() {
  try {
    await SecureStore.deleteItemAsync('session_token');
  } catch {
    // ここで握りつぶしていました
  }
 
  const left = await SecureStore.getItemAsync('session_token');
  if (left === null) {
    navigateToSignIn();
  }
}

読み出しによる確認は、うまくいっているときには正しく動きます。困るのは、いちばん知りたい場面でだけ嘘をつくことです。削除が失敗した回に限って null が返るのですから、検査としては向きが逆でした。

書き直したのは、たった一つの関数です。

import * as SecureStore from 'expo-secure-store';
 
type ClearResult = { cleared: boolean; reason?: string };
 
export async function clearSecret(key: string): Promise<ClearResult> {
  try {
    await SecureStore.deleteItemAsync(key);
    return { cleared: true };
  } catch (e) {
    return {
      cleared: false,
      reason: e instanceof Error ? e.message : String(e),
    };
  }
}

例外を潰す代わりに、結果として持ち上げているだけです。呼び出し側は cleared を見て、サインアウトの完了を宣言してよいかを決められるようになります。

削除できたかどうかは、読み出しではなく削除の結果で判断します。 この一行を先に決めてから、周りのコードを組み直しました。

なお、消す前にダミー値で上書きしておく手も考えました。上書きが通れば元の値は読めなくなりますので、残骸の危険は小さくなります。ただ、その書き込み自体が失敗する可能性は残ります。ですので上書きは保険として置き、判断の根拠は戻り値のままにしております。

残ってしまったときに、次の起動で取り返す

失敗を検知できるようになると、次は「検知したあとどうするか」が必要になります。ユーザーを画面の前で待たせ続けるわけにはまいりませんので、私は次の起動へ持ち越す形にしました。

控えを置く場所は、秘密ではない側のストレージで足ります。残すのは鍵の名前だけで、値は持たせません。

import AsyncStorage from '@react-native-async-storage/async-storage';
 
const PENDING_KEY = 'secure_cleanup_pending';
 
async function readPending(): Promise<string[]> {
  const raw = await AsyncStorage.getItem(PENDING_KEY);
  return raw ? (JSON.parse(raw) as string[]) : [];
}
 
export async function requestClear(key: string): Promise<boolean> {
  const result = await clearSecret(key);
  if (result.cleared) {
    return true;
  }
 
  const pending = await readPending();
  if (!pending.includes(key)) {
    pending.push(key);
    await AsyncStorage.setItem(PENDING_KEY, JSON.stringify(pending));
  }
  return false;
}
 
// アプリ起動直後、最初の画面を出す前に一度だけ呼びます
export async function retryPendingClears(): Promise<void> {
  const pending = await readPending();
  if (pending.length === 0) {
    return;
  }
 
  const stillLeft: string[] = [];
  for (const key of pending) {
    const result = await clearSecret(key);
    if (!result.cleared) {
      stillLeft.push(key);
    }
  }
 
  if (stillLeft.length > 0) {
    await AsyncStorage.setItem(PENDING_KEY, JSON.stringify(stillLeft));
  } else {
    await AsyncStorage.removeItem(PENDING_KEY);
  }
}

retryPendingClears起動直後に置くところが要点です。マップがディスクから読み直された直後であれば、前回失敗した削除がそのまま通ることがあります。私の端末では、この一手で残骸が消えました。

控えを AsyncStorage に置いているのは、SecureStore が書けない状況で SecureStore に控えを書こうとしても仕方がないためです。ここに入るのは鍵の名前だけですので、読まれて困るものは含みません。

再試行は何度走っても同じ結果になるように書いてあります。すでに消えている鍵に対する削除は、例外を投げずに通ります。控えが残り続ける場合は、ストレージ側に別の問題があると考えて、そこで調べ始めるのがよいのだと思います。

壊れた状態のまま起動を繰り返す設計には、別の危うさもあります。起動時に走る処理を増やすときの線引きについては、壊れたキャッシュで毎回起動時に落ちるアプリ——ユーザーが自力で抜け出せる「セーフモード起動」を設計するにまとめております。

端末の後始末より先に、サーバー側を失効させます

ここまで書いておいて申し上げるのも妙ですが、端末に残るかどうかを完全に制御することは、そもそもこちら側にはできません。ストレージが逼迫していても、OS 側の事情で書き込みが弾かれても、結果は同じです。

ですので、順番を変えました。

  1. サーバーへ失効を投げます(成功を待ちます)
  2. 端末の値を clearSecret で消します
  3. 消せなければ控えを置き、次の起動で再試行します

この並びにすると、今度は「電波が無いところでサインアウトを押された場合」が残ります。1 の成功を待てませんので、そのままでは先へ進めません。

私が選んだのは、端末側に「このセッションはもう使わない」という印を先に立てる方法です。印が立っている間、アプリはトークンを読み出しても API 呼び出しには使いません。そのうえで、失効の送信と値の削除を控えに積み、次に通信できたときと、次の起動時に、それぞれ再試行します。ユーザーから見れば、押した瞬間にサインアウトは終わっています。

この順番であれば、2 と 3 が転んでも、端末に残るのはもう通らない文字列です。端末の削除は後始末であって、安全の根拠ではありません。 私はこの並びだけは、急いでいる日でも崩さないようにしております。

秘密の置き場所そのものを見直すなら、ビルドに焼き込まれる値との切り分けも一度整理しておくと迷いが減ります。そちらはEAS の secret は「アプリに入れない」設定ではありません — 接頭辞と可視性を別々に決めるに書き残しました。

今日、最初に触る1か所

サインアウト処理の catch を開いてみてください。そこで何も書かずに閉じている波かっこがあれば、それを戻り値に変えるところから始められます。関数一つ分の手間で、「消えたつもり」がどれくらい起きているかが見えるようになります。

私はこの確認を、公開中のアプリを順に開いて一つずつ進めております。手が速く動く作業ではありませんが、あとから取り返せない種類の取りこぼしですので、静かに続けるほかないのだと感じています。

最後までお読みいただき、ありがとうございました。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥2,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

アプリ開発2026-08-18
Rork のプロジェクトで targetSdkVersion 36 を通すまでに直した3か所
app.json に targetSdkVersion 36 と書いてあるのに、ビルドが読んでいた値は 35 でした。実効値を報告するスクリプトと、引き上げ・更新停止・延長申請の三択の分け方を残します。
アプリ開発2026-09-10
Rork のリポジトリに置く最初の EAS Workflow と、失敗した回にだけ届かない通知
Rork から書き出したリポジトリに .eas/workflows を二本置くまでの手順と、needs でつないだ通知が失敗した回にだけ届かなくなる理由を、手元で走る検査スクリプトの実行結果つきで書き残します。
アプリ開発2026-08-23
targetSdk を 36 に上げたら 16 KB の警告が出ました。この2つは別の期限です
対象 API レベル36 の8月31日と、16 KB メモリページサイズの2027年2月1日は別の条件です。NDK を入れずに手元の AAB を検査する方法と、LOAD のずれと zip 境界のずれで直し方が変わることを、実際の出力とあわせて整理しました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます