WinFormsアプリにWebView2を組み込み、リンクの「新しいウィンドウ」をタブとして開く構成は実務でよく使われます。一方で、タブを開くたびにログインが要求される、閉じたはずの状態が残るなど、認証まわりが不安定になるケースも少なくありません。ここでは原因の考え方から、セッションを維持したままタブを増減できる実装の要点まで、具体例つきで整理します。
症状を正しく整理する
まず、現象を「Webアプリ側の挙動」ではなく「埋め込みブラウザーのプロファイル(永続ストア)設計」として整理すると、解決が一気に近づきます。
- WinFormsにWebView2でWebフォーム(Webアプリ)を埋め込んでいる
NewWindowRequestedなどで「新しいウィンドウ」をタブ内の子WebView2に割り当てている(e.Handled = true)- タブを閉じて子WebView2を破棄したいが、期待したタイミングで破棄されず(WM_DESTROY依存の破棄が不安定に見える)、状態が残る/壊れ方がまちまち
- 結果として、タブを開くたびに毎回ログイン(再認証)になる
- 目的は「認証状態(セッション)を維持したままタブを増減したい」
| 困っていること | 見え方 | 本質 |
|---|---|---|
| タブを開くたびに再認証 | 別ブラウザーとして扱われているように見える | Cookie/ストレージが「同一プロファイル」になっていない可能性が高い |
| タブを閉じても状態が残る | WM_DESTROYが呼ばれない/破棄が不安定 | WinFormsのHandleライフサイクルとWebView2の非同期初期化・ブラウザープロセスの都合が絡む |
| 安定してタブを増減したい | 同じログイン状態を保ちたい | WebView2間で永続ストア(User Data Folder)を共有し、同一環境として扱う |
なぜ「新しいタブ」で再認証が起きるのか
WebView2は「見た目はコントロール」ですが、中身はEdge(Chromium)相当のブラウザー機能です。ログイン状態は主にCookieや各種ストレージに保存されます。つまり、どのWebView2がどの永続ストアを使っているかで、認証が引き継がれるかどうかが決まります。
ここで重要なのが、WebView2の初期化に関わる次の考え方です。
- CoreWebView2Environment:ブラウザーの実行環境(どのプロファイル領域を使うか等)
- User Data Folder:Cookie/キャッシュ/Local Storageなどを保存する「プロファイル格納先」
- WebView2インスタンス:タブごとに作るUIコントロール。インスタンスが別でも同じEnvironment(同じUser Data Folder)なら「同一ブラウザーの別タブ」に近い振る舞いになる
逆に、タブごとにWebView2を新規作成する際に、次のような状態になっていると「別ブラウザー扱い」になりやすく、再認証が発生します。
- タブごとに別のUser Data Folderを使っている(例:一時フォルダに毎回作る/GUID付きフォルダを毎回作る)
- タブごとに別のEnvironmentを作っていて、結果として別プロファイルになる(または共有されないタイミングがある)
- InPrivate相当の設定で、そもそも永続化しない(閉じるたびにCookieが消える)
| ログイン維持に関係する要素 | 保存先のイメージ | 共有されないと起きがちなこと |
|---|---|---|
| Cookie(セッションCookie/永続Cookie) | User Data Folder(プロファイル) | 別プロファイルだと「未ログイン」扱い |
| Local Storage / IndexedDB | User Data Folder(プロファイル) | トークンや状態が見つからず再ログイン |
| Session Storage | タブ(ページ)単位の性質が強い | 「新しいタブ」は別セッションとして扱われる設計のWebアプリでは再認証に見えることも |
| キャッシュ | User Data Folder(プロファイル) | 毎回重い画面遷移・SSOリダイレクトが増える |
今回のように「新しいタブを開くたびに毎回ログイン」という症状は、まずCookie/ストレージがタブ間で共有されていないと疑うのが最短ルートです。
結論:全WebView2で同じUser Data Folderを共有する
解決策の核はシンプルで、全てのタブが同じ永続ストア(User Data Folder)を使うように統一することです。実装としては、次のどちらか(または併用)になります。
- 推奨:共有の
CoreWebView2Environmentを1回だけ生成し、全タブのWebView2をその環境で初期化する - 簡易策:各WebView2の
CreationProperties.UserDataFolderを同一パスに揃える(ただし環境生成の重複が起きやすいので注意)
| 方式 | セッション共有 | 初期化コスト | 実装の安定性 | おすすめ度 |
|---|---|---|---|---|
| 共有Environmentを使い回す | ◎(同一プロファイルに寄せやすい) | ◎(環境生成は1回) | ◎(設計が明確) | 高 |
| UserDataFolderだけ揃える | ○(揃えば共有できる) | △(タブごとに環境生成が走ることがある) | △(タイミング差でハマりやすい) | 中 |
| タブごとにUserDataFolderを分ける | ×(別セッション) | ×(ディスク増・管理増) | ○(意図的に分けたい場合のみ) | 用途限定 |
つまり「同じブラウザーの複数タブ」のように扱いたいなら、同一Environment(同一User Data Folder)で揃えるのが肝です。これでCookieを含むセッションが共有され、タブを閉じても(プロファイルが残る限り)ログイン状態を維持できます。
実装例:WinForms + TabControl + WebView2でセッション維持
ここからは、実際にWordPress読者がコピペしやすい形で、最小構成の実装例を示します。ポイントは次の3つです。
- 共有
CoreWebView2Environmentを「一度だけ」作る - タブ作成時は必ず共有環境で
EnsureCoreWebView2Asyncする NewWindowRequestedはDeferralを使い、タブ生成を非同期で安全に行う
共有Environmentを1回だけ生成する
User Data Folderは、一般的にはユーザーごとのローカル領域(LocalAppData配下)に置くのが無難です。Program Files配下や権限が厳しい場所に置くと、更新や書き込み権限でトラブルになりやすいので避けます。
| 候補 | 例 | おすすめ | 理由 |
|---|---|---|---|
| LocalApplicationData | %LOCALAPPDATA%\Company\App\WebView2 | ◎ | ユーザーごと・権限トラブルが少ない・一般的 |
| ApplicationData(Roaming) | %APPDATA%\Company\App\WebView2 | △ | 同期・サイズ増の懸念。運用要件次第 |
| Temp配下 | %TEMP%\WebView2 | × | 掃除される・永続化しない・再認証の温床 |
共有Environmentを作るコード例です(Task<T>などのジェネリクス記号はHTML上で崩れないようにエスケープしています)。
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Web.WebView2.Core;
public static class WebView2EnvironmentProvider
{
private static readonly object _lock = new object();
private static Task<CoreWebView2Environment> _envTask;
public static Task<CoreWebView2Environment> GetAsync()
{
lock (_lock)
{
if (_envTask != null) return _envTask;
var userDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"YourCompany",
"YourApp",
"WebView2");
Directory.CreateDirectory(userDataFolder);
// 必要に応じてオプションを設定(例:言語や追加引数など)
var options = new CoreWebView2EnvironmentOptions();
_envTask = CoreWebView2Environment.CreateAsync(
browserExecutableFolder: null,
userDataFolder: userDataFolder,
options: options);
return _envTask;
}
}
}
このように「環境生成を必ず1回に固定」しておくと、タブが増えても減っても、同じ永続ストアを前提に実装できます。加えて、環境生成コスト(初回起動のもっさり感)を抑えられるのも現場では効きます。
タブ用WebView2を共有Environmentで初期化する
タブ(TabPage)にWebView2を載せる関数を1つ用意しておくと、NewWindowRequestedでも「通常のタブ追加」でも同じ処理が使えて保守性が上がります。
using System.Threading.Tasks;
using System.Windows.Forms;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
public static class WebView2TabFactory
{
public static async Task<WebView2> CreateAsync(Control host, CoreWebView2Environment env)
{
var webView = new WebView2
{
Dock = DockStyle.Fill
};
host.Controls.Add(webView);
// 共有環境で初期化(重要)
await webView.EnsureCoreWebView2Async(env);
// 初期化後に設定・イベント購読(CoreWebView2がnullでなくなる)
webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
webView.CoreWebView2.Settings.IsStatusBarEnabled = false;
return webView;
}
}
ここでのポイントは、EnsureCoreWebView2Asyncを必ず共有envで呼ぶことです。タブごとにEnsureCoreWebView2Async()(引数なし)を呼んでしまうと、環境の作られ方が揺れて「意図せず別プロファイル」になる温床になりがちです。
NewWindowRequestedをタブに割り当てる(Deferralが肝)
Webアプリ側がwindow.openやtarget="_blank"で新規ウィンドウを要求すると、WebView2のCoreWebView2.NewWindowRequestedが発火します。ここでタブを作って「新しいWebView2に遷移させたい」場合、非同期初期化が絡むのでDeferralを使うのが安全です。
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
public partial class MainForm : Form
{
public MainForm()
{
InitializeComponent();
}
private async Task<WebView2> AddNewTabAsync(string title, string initialUrl = null)
{
var env = await WebView2EnvironmentProvider.GetAsync();
var tabPage = new TabPage(title);
tabControl1.TabPages.Add(tabPage);
tabControl1.SelectedTab = tabPage;
var webView = await WebView2TabFactory.CreateAsync(tabPage, env);
// NewWindowRequestedは初期化後に購読
webView.CoreWebView2.NewWindowRequested += CoreWebView2_NewWindowRequested;
if (!string.IsNullOrWhiteSpace(initialUrl))
{
webView.Source = new Uri(initialUrl);
}
return webView;
}
private async void CoreWebView2_NewWindowRequested(object sender, CoreWebView2NewWindowRequestedEventArgs e)
{
var deferral = e.GetDeferral();
try
{
// 新しいタブを作り、そこへNewWindowを割り当てる
var env = await WebView2EnvironmentProvider.GetAsync();
var tabPage = new TabPage("New Tab");
tabControl1.TabPages.Add(tabPage);
tabControl1.SelectedTab = tabPage;
var childWebView = await WebView2TabFactory.CreateAsync(tabPage, env);
childWebView.CoreWebView2.NewWindowRequested += CoreWebView2_NewWindowRequested;
// ここが要点:NewWindowにCoreWebView2を渡す
e.NewWindow = childWebView.CoreWebView2;
e.Handled = true;
}
finally
{
deferral.Complete();
}
}
}
同じenv(同じUser Data Folder)で初期化しているため、親タブで確立した認証Cookieやストレージが子タブでも参照でき、再認証が大幅に減ります。タブを閉じて再度開いても、プロファイルが残っている限りはログイン状態が引き継がれます。
タブを閉じるときは「Disposeを確実に」行う
WM_DESTROY頼みの破棄は、非同期初期化・コントロールのHandle生成タイミング・親子関係の入れ替えなどが絡むと揺れます。現場で安定させるコツは、「WinFormsのイベント」ではなく「自分が閉じる瞬間」にDisposeまで確実に行うことです。
| やること | 目的 | 効果 |
|---|---|---|
| CoreWebView2のイベント解除 | 参照が残ってGCされないのを防ぐ | メモリリーク・予期せぬコールバックを防止 |
| タブからWebView2をRemove | Controlツリーから外す | 破棄の確実性が上がる |
| webView.Dispose() | COM/ネイティブリソース解放 | ブラウザー関連のリソースが安定して解放される |
| TabPage.Dispose() | 親コンテナも解放 | 後腐れが減る |
using System.Linq;
using System.Windows.Forms;
using Microsoft.Web.WebView2.WinForms;
public partial class MainForm : Form
{
private void CloseTab(TabPage tabPage)
{
if (tabPage == null) return;
var webView = tabPage.Controls.OfType<WebView2>().FirstOrDefault();
if (webView != null)
{
try
{
if (webView.CoreWebView2 != null)
{
webView.CoreWebView2.NewWindowRequested -= CoreWebView2_NewWindowRequested;
// 状況により、停止やabout:blank遷移で後片付けが安定することがあります
webView.CoreWebView2.Stop();
webView.CoreWebView2.Navigate("about:blank");
}
}
catch
{
// 破棄中の例外はアプリ要件に合わせてログへ
}
tabPage.Controls.Remove(webView);
webView.Dispose();
}
tabControl1.TabPages.Remove(tabPage);
tabPage.Dispose();
}
}
「WM_DESTROYが呼ばれない気がする」という悩みは、実際には破棄の入り口が複数あってタイミングが一定しないことが原因になりやすいです。タブUIのクローズ操作を一箇所に集約し、その中で明示的にDisposeまで完了させると挙動が安定します。
WM_DESTROYに依存するとハマりやすい理由
WinFormsでは、コントロールのHandleが作られるタイミングが状況で変わることがあります(表示されていない、親が切り替わる、作ってすぐDisposeする等)。WebView2はさらに、内部でブラウザープロセスやレンダラープロセスを持ち、初期化も非同期です。つまり、
- 「タブを閉じた=すぐにネイティブウィンドウが破棄される」ではない
- 「WM_DESTROYが必ずこの順番で来る」ことを前提にした解放は揺れやすい
- イベント購読が残ると参照が切れず、思ったより長く生きることがある
このため、WebView2を使う場合は、WinFormsのメッセージに寄せるよりも、アプリのUI操作(タブ閉じ)に合わせてDisposeを明示する方が、結果的に不具合が減ります。
よくある落とし穴(再認証が直らないときのチェックポイント)
共有Environmentを導入しても再認証が続く場合、原因は「実は共有できていない」か「認証がCookie以外で設計されている」かのどちらかに寄ります。まずは次の表で潰すのが効率的です。
| チェック項目 | 確認方法 | 対策 |
|---|---|---|
| User Data Folderが毎回変わっていないか | コード上のパス/フォルダ生成ログを確認 | 固定パスにする(LocalAppData配下推奨) |
| タブごとにEnvironmentを作っていないか | CreateAsync呼び出し箇所を検索 | Providerで一元化し「1回だけ」にする |
| InPrivate相当が有効になっていないか | CreationProperties.IsInPrivateModeEnabled等の設定を確認 | 意図しない場合は無効化する |
| NewWindowRequestedが「別プロセス/別環境」で開かれていないか | 子タブ生成が共有envを使っているか確認 | 必ず共有envでEnsureする(Deferral推奨) |
| ログインがセッションCookieではなく「タブ単位」の設計になっていないか | 開くたびにSSOやMFAが走るか観察 | Webアプリ側の仕様(sessionStorageなど)も考慮し、URL/フローを見直す |
セッション共有を「見える化」して確信を持つ
「本当にCookieが共有されているのか?」を確認できるようにしておくと、問題の切り分けが速くなります。例えば、ログイン後に特定ドメインのCookieが存在するかを取得してログに出すだけでも、原因追跡が容易になります。
using System;
using System.Threading.Tasks;
using Microsoft.Web.WebView2.WinForms;
public static class WebView2Diagnostics
{
public static async Task DumpCookiesAsync(WebView2 webView, string url)
{
if (webView?.CoreWebView2 == null) return;
var cookies = await webView.CoreWebView2.CookieManager.GetCookiesAsync(url);
Console.WriteLine($"Cookie count for {url}: {cookies.Count}");
foreach (var c in cookies)
{
Console.WriteLine($"{c.Name}={c.Value} Domain={c.Domain} Path={c.Path} Expires={c.Expires}");
}
}
}
これを親タブ・子タブで比較し、同じURLに対して同じCookieが見えているなら、ストア共有はできています。逆に、子タブだけCookieが空なら、Environment/User Data Folderが分かれている可能性が高いです。
それでも再認証される場合に考えること
共有EnvironmentでCookieが見えているのに再認証されるときは、Webアプリ側の設計・認証フローを疑う段階です。特に次のパターンは埋め込みWebViewで起きやすいです。
- 認証状態がCookieではなく、sessionStorage中心で保持されている:「新しいタブ=別セッション」になりやすい
- SSOのリダイレクトで毎回prompt=login相当が付与される:同じCookieがあってもログインを強制される
- 別ドメインへの遷移(認証ドメインとアプリドメインが異なる):第三者CookieやSameSite設定、フローの相性で差が出る
- タブ生成時のタイミング問題:NewWindowRequested処理が完了する前にナビゲーションが進み、意図しない別経路になる
この場合の実務的な対処としては、次が効きます。
- NewWindowRequestedは必ずDeferralを使い、子WebView2の初期化完了後に
e.NewWindowをセットする - 認証ドメインとアプリドメインのどちらのCookieを見てログイン判定しているかを把握する
- 「新規タブ」ではなく「同一タブ遷移」に寄せられないか(Webアプリ側のリンク設計も含め)検討する
運用・セキュリティ面の注意点
User Data Folderを共有し、ログイン状態を永続化するということは、裏を返せば「ディスク上に認証関連データを保持する」ことでもあります。業務アプリでは便利な一方、運用設計をしておくと安心です。
| 論点 | 注意点 | 実務での落としどころ |
|---|---|---|
| 共有PC/複数ユーザー | 同一Windowsユーザーでアプリを共用するとセッションが共有される | Windowsアカウント分離、またはアプリ内でログアウト時にCookie削除 |
| ログアウト実装 | Webアプリのログアウトだけでは残るデータがあることも | 必要に応じてCookie/キャッシュの削除を用意 |
| データ容量 | キャッシュが増えて肥大化する | 定期クリア、もしくは運用手順での削除 |
例えば「アプリのログアウト」ボタンでCookieを全削除するだけでも、共用端末の事故を減らせます。
public static void ClearAllCookies(Microsoft.Web.WebView2.WinForms.WebView2 webView)
{
if (webView?.CoreWebView2 == null) return;
webView.CoreWebView2.CookieManager.DeleteAllCookies();
}
まとめ:タブを“別ブラウザー”にしない設計が鍵
WinForms + WebView2で「新しいタブを開くたびに再認証される」問題は、タブ処理やWM_DESTROYの話に見えて、実際はセッションを載せる器(User Data Folder / Environment)の統一が原因になっていることが多いです。
- 再認証の多くは「タブごとに別プロファイル」になっていることが引き金
- 共有User Data Folderを使う共有Environmentを1回だけ作り、全タブで使い回す
- NewWindowRequestedはDeferralで安全にタブ生成し、子WebView2にも同じEnvironmentを適用する
- 破棄はWM_DESTROYに頼らず、タブクローズ操作でイベント解除+Disposeまで確実に行う
この設計に揃えると、WebView2が「同一プロファイル内の複数タブ」として動きやすくなり、タブ増減と認証維持の両立が現実的になります。認証が絡む埋め込みWebアプリほど、まずはEnvironmentとUser Data Folderの統一から手を付けるのが近道です。

コメント