Blazor Serverでページ状態をセキュアに保持する方法|LocalStorage/Cookie禁止でもボタン状態を復元

Blazor Server で「ボタンの有効/無効」といったページ状態を保持したいのに、Cisco CSDL の要件で LocalStorage/SessionStorage/Cookie を使えない――そんな環境でも破綻しない設計は「状態をサーバー側に永続化する」ことです。本記事では、SQL Server Replication の管理画面を例に、ユーザーごとに状態を復元できる実装パターンを具体的に解説します。

目次

前提整理:なぜBlazor Serverでも「状態が消える」のか

Blazor Server はクライアントで .NET を動かすのではなく、サーバー側でコンポーネントを実行し、UI差分を SignalR でブラウザへ送ります。つまり「接続中」はサーバー側メモリ(Circuit)に状態が残りますが、次のタイミングで簡単に失われます。

  • ログアウトした
  • ブラウザを閉じた/接続が切れた
  • アプリが再起動・リサイクルされた
  • スケールアウト構成で別ノードへ切り替わった

今回の要件は「ログアウト後も復元」なので、Circuit(インメモリ)に置くだけでは必ず破綻します。さらに LocalStorage/SessionStorage/Cookie に保存できないため、一般的な“ブラウザにセッションIDを持たせる”方式も封じられます。

要件を「設計条件」に落とし込む

質問の条件を、実装で守るべき設計条件として整理します。

要件実装上の条件避けるべきこと
ブラウザ側ストレージにアプリデータを保存できない状態はサーバー側に永続化するLocalStorage/SessionStorage/Cookieへの状態保存
ボタン状態(インストール/アンインストール/再初期化)を制御「ボタン状態」ではなく「業務上の状態」を保存しUIを導出するdisabledフラグだけを保存して意味が崩れること
ユーザーごとに保持し、ログアウト後も復元ユーザーID(または同等の識別子)で状態をひも付ける接続(Circuit)依存のScoped状態のみで完結させる
Cookieは暗号化JWTのみ許可認証はJWT中心で設計し、状態本体はサーバー側に置く巨大なJWTに状態を詰め込む/運用で破綻すること

結論:ページ状態は「サーバー側に永続化」するのが最短で安全

ブラウザに何も持たせられない以上、状態はサーバー側の永続ストアに保存するのが基本方針です。ここで重要なのは、保存対象を「ボタンのON/OFF」ではなく、UIの根拠となる“業務上の状態(ドメイン状態)”にすることです。

例:SQL Server Replication 管理画面なら、最低限このような状態があると設計が安定します。

  • レプリケーションがインストール済みか(IsInstalled)
  • 再初期化が可能か(CanReinitialize)
  • 操作が実行中か(InProgress)
  • 最後に確認した実状態の時刻(LastCheckedAt)
  • 最後の操作と結果(LastAction/LastError)

保存先の候補と選び方

保存先は「確実に残る」ことが最優先です。よく使われる選択肢を比較します。

保存先向いている用途メリット注意点
SQL Server / Azure SQLユーザーごとの画面状態、監査が必要永続性・検索性・トランザクション・監査ログと相性が良い高頻度更新は設計(集約・キャッシュ)次第で負荷に
Key-Valueストア(例:Redis)高速な読み書き、短期状態高速・スケールしやすい永続化設定・TTL設計を誤ると消える。監査用途には弱い
テーブル/NoSQL(例:Azure Table)シンプルな状態、コスト最適構造が単純なら運用しやすい複雑な検索・結合は苦手
Blob(JSONファイル)状態が大きい・頻度が低い実装が単純同時更新・整合性を自前で担保しにくい

今回のように「ユーザーごとに復元」「監査や運用も考えたい」「SQL Server Replication管理の文脈でDBが近い」なら、まずは SQL Server に置く設計が最も読みやすく堅牢です。

ボタン状態を“保存”しない:状態から“導出”する

「インストール済みならアンインストール/再初期化を有効」「アンインストールしたらインストールだけ有効」といった要件は、ボタンのdisabledフラグを保存するより、次のように状態遷移として表現すると破綻しにくくなります。

おすすめの状態モデル(最小)

まずは最小で成立するモデルを作り、UIはそこから導出します。

public sealed class ReplicationState
{
    public bool IsInstalled { get; set; }
    public bool CanReinitialize { get; set; }

    // 操作中の二重クリック対策(任意だが実運用ではほぼ必須)
    public bool InProgress { get; set; }

    // 運用で役立つ情報
    public DateTimeOffset LastUpdatedAt { get; set; }
    public string? LastAction { get; set; }
    public string? LastError { get; set; }
}

UI側では例えば次のように判断します(「ボタン状態」を保存しない)。

  • インストールボタン:!state.IsInstalled && !state.InProgress のとき有効
  • アンインストールボタン:state.IsInstalled && !state.InProgress のとき有効
  • 再初期化ボタン:state.IsInstalled && state.CanReinitialize && !state.InProgress のとき有効

状態遷移を表にする(実装の迷いが減る)

状態インストールアンインストール再初期化備考
未インストール(IsInstalled=false)有効無効無効初期状態。必要ならDBにレコードを作成
インストール済み(IsInstalled=true)無効有効CanReinitialize=trueなら有効再初期化可否は運用ルールで決まることが多い
操作中(InProgress=true)無効無効無効二重実行防止。失敗時はLastErrorに記録

この“表現の固定”が、後から仕様が増えたとき(例:インストール中、失敗状態、権限不足など)に効きます。

ユーザーごとのキー設計:何で状態をひも付けるか

サーバー側に保存するには「誰の状態か」を示すキーが必要です。結論としては、認証済みユーザーが前提なら ユーザーID(Claim) をキーにするのが最も単純で堅牢です。

推奨:UserIdを主キーにする(認証前提)

ASP.NET Core の認証(JWT等)で ClaimsPrincipal を確立できるなら、例えば sub(Subject)や NameIdentifier をDBキーにします。さらに「対象のSQL Serverインスタンス」や「環境(Prod/Stg)」が複数あるなら複合キーにします。

  • 単一ターゲット:(UserId)
  • 複数ターゲット:(UserId, TargetServerId)
  • マルチテナント:(TenantId, UserId, TargetServerId)

匿名ユーザーをどうするか(制約が厳しい場合)

匿名ユーザーも「ログアウト後に復元」したい場合、通常はCookie等でブラウザに“紐付けキー”を保持します。しかし今回それができません。許可されているのが暗号化済みJWTのみであれば、現実的な方針は次のどちらかです。

  • 要件を認証前提に寄せる:「ユーザーごと」を満たす最短経路。ログインIDで復元する
  • 暗号化JWTにセッションキーだけ入れる:状態本体は入れず、サーバー保存のレコードを引くキー(GUID等)だけを持たせる

ただし後者は「JWTをどこに保持するか」という問題に必ず突き当たります。Cookieが暗号化JWTに限り許可されているなら、そのCookieにJWTを入れて運用する設計が最も現実的です(JWTはHttpOnlyにしてJavaScriptから読めないようにするのが定石です)。

実装例:SQL Serverに状態を保存してBlazor Serverで復元する

ここからは、SQL Server(EF Core)で UserReplicationState を持ち、ページ初期化時に読み込んでボタン状態を決定する構成を例示します。ポイントは「UIに必要な状態を1レコードに集約」し、「更新競合(同時更新)に耐える」ことです。

テーブル設計例

まずは素直にテーブルで持ちます。RowVersion を入れて楽観ロックできるようにしておくと、運用での事故(別端末・別タブ同時操作)が減ります。

CREATE TABLE dbo.UserReplicationState (
    UserId              NVARCHAR(200) NOT NULL,
    TargetServerId      NVARCHAR(200) NOT NULL,
    IsInstalled         BIT NOT NULL,
    CanReinitialize     BIT NOT NULL,
    InProgress          BIT NOT NULL,
    LastAction          NVARCHAR(50) NULL,
    LastError           NVARCHAR(MAX) NULL,
    LastUpdatedAt       DATETIMEOFFSET(7) NOT NULL,
    RowVersion          ROWVERSION NOT NULL,
    CONSTRAINT PK_UserReplicationState PRIMARY KEY (UserId, TargetServerId)
);

「ボタン状態を保存する」より、「状態を保存してUIで導出する」方が、長期的に仕様変更に耐えます。例えば後から InstallRequestedAt や LastCheckedAt を足しても、UI側は“状態から導出”なので破壊的変更が起こりにくくなります。

EF Coreエンティティ例

public sealed class UserReplicationStateEntity
{
    public string UserId { get; set; } = default!;
    public string TargetServerId { get; set; } = default!;


public bool IsInstalled { get; set; }
public bool CanReinitialize { get; set; }
public bool InProgress { get; set; }

public string? LastAction { get; set; }
public string? LastError { get; set; }
public DateTimeOffset LastUpdatedAt { get; set; }

public byte[] RowVersion { get; set; } = default!;


}
public sealed class AppDbContext : DbContext
{
    public DbSet<UserReplicationStateEntity> UserReplicationStates => Set<UserReplicationStateEntity>();

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var e = modelBuilder.Entity<UserReplicationStateEntity>();
        e.HasKey(x => new { x.UserId, x.TargetServerId });
        e.Property(x => x.RowVersion).IsRowVersion();
        e.Property(x => x.LastUpdatedAt).HasPrecision(7);
    }
}

リポジトリ/ストアを切り出す(後からRedis等へ差し替え可能)

最初から抽象化しすぎる必要はありませんが、「状態を読む/書く」という責務を1箇所に集めるとセキュリティ要件や監査要件に対応しやすくなります。

public interface IReplicationStateStore
{
    Task<ReplicationState> GetAsync(string userId, string targetServerId, CancellationToken ct);
    Task SaveAsync(string userId, string targetServerId, ReplicationState state, CancellationToken ct);
}
public sealed class SqlReplicationStateStore : IReplicationStateStore
{
    private readonly AppDbContext _db;

    public SqlReplicationStateStore(AppDbContext db) => _db = db;

    public async Task<ReplicationState> GetAsync(string userId, string targetServerId, CancellationToken ct)
    {
        var entity = await _db.UserReplicationStates
            .AsNoTracking()
            .FirstOrDefaultAsync(x => x.UserId == userId && x.TargetServerId == targetServerId, ct);

        if (entity is null)
        {
            // 初回アクセスは「未インストール」として作成してもよい(要件次第)
            return new ReplicationState
            {
                IsInstalled = false,
                CanReinitialize = false,
                InProgress = false,
                LastUpdatedAt = DateTimeOffset.UtcNow,
                LastAction = "Initialized",
                LastError = null
            };
        }

        return new ReplicationState
        {
            IsInstalled = entity.IsInstalled,
            CanReinitialize = entity.CanReinitialize,
            InProgress = entity.InProgress,
            LastUpdatedAt = entity.LastUpdatedAt,
            LastAction = entity.LastAction,
            LastError = entity.LastError
        };
    }

    public async Task SaveAsync(string userId, string targetServerId, ReplicationState state, CancellationToken ct)
    {
        var entity = await _db.UserReplicationStates
            .FirstOrDefaultAsync(x => x.UserId == userId && x.TargetServerId == targetServerId, ct);

        if (entity is null)
        {
            entity = new UserReplicationStateEntity
            {
                UserId = userId,
                TargetServerId = targetServerId
            };
            _db.UserReplicationStates.Add(entity);
        }

        entity.IsInstalled = state.IsInstalled;
        entity.CanReinitialize = state.CanReinitialize;
        entity.InProgress = state.InProgress;
        entity.LastAction = state.LastAction;
        entity.LastError = state.LastError;
        entity.LastUpdatedAt = state.LastUpdatedAt;

        await _db.SaveChangesAsync(ct);
    }
}

この時点で「ログアウトしても復元」は達成できます。なぜなら、状態のソースがブラウザではなくDBだからです。

Blazor Serverの画面実装:初期化で読み込み、操作で更新する

次に、Blazorコンポーネント(.razor)での基本形です。ポイントは「画面初期表示で読み込む」「操作完了時に必ずサーバー側状態を更新する」「実行中は二重実行を防ぐ」の3つです。

UIの状態を保持するフィールドとボタン判定

@page "/replication"
@using System.Security.Claims
@inject IReplicationStateStore StateStore
@inject AuthenticationStateProvider AuthStateProvider

SQL Server Replication 管理

@if (_state is null)
{
読み込み中...
}
else
{


状態@(_state.IsInstalled ? "インストール済み" : "未インストール")
再初期化@(_state.CanReinitialize ? "可能" : "不可")
最終更新@_state.LastUpdatedAt.LocalDateTime




@if (!string.IsNullOrWhiteSpace(_state.LastError))
{
    <p><strong>エラー:</strong> @_state.LastError</p>
}

<div>
    <button disabled="@IsInstallDisabled" @onclick="InstallAsync">インストール</button>
    <button disabled="@IsUninstallDisabled" @onclick="UninstallAsync">アンインストール</button>
    <button disabled="@IsReinitDisabled" @onclick="ReinitializeAsync">再初期化</button>
</div>


}

@code {
private ReplicationState? _state;
private string _targetServerId = "DefaultInstance"; // 例:画面で選択できるなら選択値を入れる


private bool IsInstallDisabled => _state is null || _state.InProgress || _state.IsInstalled;
private bool IsUninstallDisabled => _state is null || _state.InProgress || !_state.IsInstalled;
private bool IsReinitDisabled => _state is null || _state.InProgress || !_state.IsInstalled || !_state.CanReinitialize;

protected override async Task OnInitializedAsync()
{
    var userId = await GetUserIdAsync();
    _state = await StateStore.GetAsync(userId, _targetServerId, CancellationToken.None);
}

private async Task<string> GetUserIdAsync()
{
    var auth = await AuthStateProvider.GetAuthenticationStateAsync();
    var user = auth.User;

    // 代表例:sub、NameIdentifierなど環境に合わせる
    var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)
              ?? user.FindFirstValue("sub")
              ?? throw new InvalidOperationException("ユーザーIDが取得できません。");

    return userId;
}

private async Task InstallAsync()
{
    if (_state is null) return;

    await RunWithProgressAsync("Install", async () =>
    {
        // ここで実際のインストール処理(SQL Serverへの操作)を呼ぶ
        // await ReplicationApi.InstallAsync(...);

        _state.IsInstalled = true;
        _state.CanReinitialize = true;
    });
}

private async Task UninstallAsync()
{
    if (_state is null) return;

    await RunWithProgressAsync("Uninstall", async () =>
    {
        // await ReplicationApi.UninstallAsync(...);

        _state.IsInstalled = false;
        _state.CanReinitialize = false;
    });
}

private async Task ReinitializeAsync()
{
    if (_state is null) return;

    await RunWithProgressAsync("Reinitialize", async () =>
    {
        // await ReplicationApi.ReinitializeAsync(...);

        // 再初期化後もインストール済みのまま、といった仕様に合わせる
        _state.CanReinitialize = false; // 例:一度実行したら不可にする運用
    });
}

private async Task RunWithProgressAsync(string action, Func<Task> operation)
{
    var userId = await GetUserIdAsync();

    try
    {
        _state!.InProgress = true;
        _state.LastAction = action;
        _state.LastError = null;
        _state.LastUpdatedAt = DateTimeOffset.UtcNow;
        await StateStore.SaveAsync(userId, _targetServerId, _state, CancellationToken.None);
        StateHasChanged();

        await operation();

        _state.InProgress = false;
        _state.LastUpdatedAt = DateTimeOffset.UtcNow;
        await StateStore.SaveAsync(userId, _targetServerId, _state, CancellationToken.None);
    }
    catch (Exception ex)
    {
        _state!.InProgress = false;
        _state.LastError = ex.Message;
        _state.LastUpdatedAt = DateTimeOffset.UtcNow;
        await StateStore.SaveAsync(userId, _targetServerId, _state, CancellationToken.None);
    }

    StateHasChanged();
}


}

この形にしておくと、ログアウトしても状態はDBに残るため、次回ログイン時に GetAsync で復元できます。

セキュアにするための重要ポイント

UI状態は“権限”ではない:操作可否は必ずサーバーで再チェックする

ボタンを無効化しても、それはUI上の抑止にすぎません。攻撃者はHTTPリクエスト相当の呼び出しを再現できます。したがって、次を徹底します。

  • インストール/アンインストール/再初期化の各処理は、必ずサーバー側で認可(Authorization)を通す
  • 「状態がこうだから実行できる」という判断も、最終決定はサーバー側で行う
  • UI状態は“表示と操作性”のためのもので、セキュリティ境界にしない

「実状態」と「保存状態」のズレ対策(現場で効く)

SQL Server Replication の“実際の状態”は本来システム側にあります。ユーザーごとの保存状態は、運用中にズレる可能性があります(別ユーザーが操作した、スクリプトで変更された、障害復旧で変わった等)。そこでおすすめは次の考え方です。

方針内容メリット実装のコツ
保存状態=“最後に確認した結果”として扱うDBの状態を唯一の真実にしないズレを許容しやすいLastCheckedAt を持たせ、必要なら再チェック
表示時に定期的に実状態を再検証起動直後や一定時間経過で再計測表示の正確性が上がる重い検査はバックエンドでキャッシュし、UIは結果を読む
操作後は必ず実状態確認→保存成功判定を“処理完了”ではなく“反映確認”にする誤表示が減る処理後にチェック関数を呼び、DBへ保存

「ボタン状態を保存する」のではなく「実状態(または実状態のスナップショット)を保存する」へ寄せるほど、後からトラブルが減ります。

更新競合を潰す:RowVersion(楽観ロック)とInProgress

同じユーザーが別タブで操作したり、通信遅延で二重クリックが起きたりすると、状態が破壊されます。対策は2段構えにします。

  • UIレベル:InProgress でボタンを全無効化し、二重実行を抑止
  • DBレベル:RowVersion(rowversion)で更新競合を検出し、後勝ち/先勝ちのルールを決める

運用の現場では「押せたから押したのに、裏で別の操作が走って失敗した」ケースが最もクレームになります。状態管理は“正しさ”と同じくらい“事故りにくさ”が重要です。

Observableパターンで「UI更新漏れ」を防ぐ(Blazor Serverと相性が良い)

状態を一箇所に集め、変更通知でUIを更新する形にすると、画面が増えても破綻しにくくなります。典型は「状態コンテナ(StateContainer)」です。ただし、これは“永続化の代替”ではありません。永続化はDB、UI反映はコンテナ、という役割分担が基本です。

public sealed class ReplicationStateContainer
{
    private ReplicationState _state = new ReplicationState();
    public ReplicationState State => _state;

    public event Action? OnChange;

    public void SetState(ReplicationState newState)
    {
        _state = newState;
        OnChange?.Invoke();
    }

    public void Mutate(Action<ReplicationState> mutate)
    {
        mutate(_state);
        OnChange?.Invoke();
    }
}

Blazor側で購読して StateHasChanged を呼べば、状態更新のたびにUIが追随します。画面が複雑になったときに「更新し忘れ」を減らせるのが最大の利点です。

暗号化JWTを使う場合の現実的な設計

「Cookieは暗号化JWTのみ許可」という条件だと、ついJWTに状態を詰め込みたくなりますが、基本はおすすめしません。理由は単純で、JWTは“認証・認可のためのトークン”であり、アプリ状態の入れ物にすると壊れやすいからです。

おすすめ:JWTには“キー”だけ、状態本体はDB

  • JWT:ユーザーID(sub)やテナント、必要最小限のクレーム
  • DB:(UserId, TargetServerId) で状態レコードを保存

どうしても“匿名でも継続”が必要で、かつ暗号化JWTが許容されるなら、JWTのクレームに StateKey(GUID)だけを入れて、サーバー側ストアから引く方式に留めると運用が安定します。

注意:JWTに状態を詰め込むと起きがちな問題

問題何が困るか回避策
トークン肥大化通信コスト増、ヘッダー制限、SignalR接続で不安定化JWTは識別子のみ、状態はDB
状態更新のたびにトークン再発行実装・運用が複雑になるサーバー側永続化で更新
失効・ローテーションとの衝突「状態が消えた」に見える障害を誘発状態はトークンに依存させない

スケールアウト/高可用性でも壊れない構成にする

Blazor Server はスケールアウトで課題が出やすい領域です(接続、Circuit、Sticky Sessionなど)。しかし今回の設計は「状態をDBに置く」ため、比較的スケールアウトしやすい部類です。追加で意識すると良い点をまとめます。

  • DBを状態のソースにする:どのWebサーバーに当たっても復元できる
  • 読み込み頻度が高いならキャッシュ:Redis等に“読み取りキャッシュ”を置き、更新時に無効化する
  • 操作系は冪等に寄せる:インストール済みなら再インストール要求は成功扱い(または明確なメッセージ)にする
  • 監査ログを別テーブルに:状態テーブルは最新状態、監査は履歴(Append-Only)で分離すると安全

よくある失敗と回避策

失敗:Scopedサービスに状態を置いて「永続化したつもり」になる

Blazor Server の Scoped は“Circuit単位”です。ログアウトや再接続で消えるため、要件の「ログアウト後も復元」を満たせません。必ずDB等へ保存してください。

失敗:disabledフラグだけ保存して仕様変更で崩壊

「インストールボタン無効」「再初期化有効」などの結果だけを保存すると、後から条件が増えた瞬間に意味が破綻します。保存すべきは IsInstalled のような“根拠となる状態”です。

失敗:エラー時にInProgressが戻らず操作不能になる

例外時にも必ず InProgress=false へ戻し、DBへ保存するようにしてください。さらに保険として「一定時間経過したらInProgressを解除する」仕組み(タイムアウト)を設けると運用が安定します。

実運用で効く設計アイデア

「ユーザーごとの画面状態」でも監査は必要になりやすい

管理画面では「誰がいつ何を押したか」が後から問われがちです。状態テーブルとは別に、操作履歴テーブルを作るのがおすすめです。

UserReplicationState  … 最新状態(上書き)
UserReplicationAudit  … 操作履歴(追記のみ)

履歴は、セキュリティレビューでも通しやすく、障害解析でも役に立ちます。

状態レコードの“寿命”を決める

「ユーザーがログアウトしても復元」といっても永久に残すとゴミが溜まります。運用ルールとして、例えば次のような整理を入れると健全です。

  • 最終更新から90日経過した状態は削除
  • 退職者/無効化ユーザーは状態も削除
  • ターゲットサーバー廃止時にまとめて削除

“状態はDB、表示はキャッシュ”の二層が強い

ページ表示のたびにDBへ行くのが気になる場合、読み取りだけキャッシュ(短いTTL)を挟み、更新時にキャッシュを無効化する構成が扱いやすいです。重要なのは、キャッシュを“唯一の保存先”にしないことです。

まとめ:Cisco CSDL制約下での最適解は「サーバー側永続化+状態からUI導出」

LocalStorage/SessionStorage/Cookie が使えない条件では、ページ状態をブラウザに持たせず、サーバー側ストレージに永続化するのが最も現実的です。ボタンの有効/無効そのものを保存するのではなく、IsInstalled や CanReinitialize といった根拠となる状態を保存し、UIはそこから導出することで、仕様変更にも強い構成になります。ユーザーIDで状態をひも付ければ、ログアウト後でも確実に復元できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次