LIFFのクエリパラメータ、10/7の仕様変更で何が変わる? liff.stateと2次リダイレクトの仕組みから対応方法まで
2026 年 10 月 7 日にリリース予定の LIFF v2.31.1 で、LIFF URL のクエリパラメータの値に含まれる ? の扱いが変わります。
パラメータの値に ? を含まないアプリは、影響を受けません。ただし、パラメータの値に URL を入れている場合や、liff.state を自前で読み取っている場合は、値が変わって届く可能性があります。
この記事では、何が変わるのかを公式の告知で確認したうえで、なぜ ? が問題になるのかを LIFF の 2 段階のリダイレクトの仕組みから説明します。そのうえで、影響を受けるかどうかの見分け方と、変更の前後どちらでも動く実装、リリース前後の確認手順をまとめます。
10/7 の仕様変更で、LIFF のクエリパラメータの何が変わるか
変更前と変更後の URL
LINE の告知はこうなっています。
2026年10月7日リリース予定のLIFF v2.31.1以降、LIFF URLのクエリパラメータの値に含まれる?の処理を変更します。これにより、LIFF URLへのアクセス時に生成される2次リダイレクト先URLが変わる場合があります。
出典: 2026年10月7日より、LIFF URLのクエリパラメータの値に含まれる「?」の処理が変更されます | LINE Developers
具体例も示されています。エンドポイント URL が https://example.com の LIFF アプリに、https://liff.line.me/{liffId}/?key=foo?bar でアクセスした場合です。
| 2次リダイレクト先 URL | |
|---|---|
| 変更前(現在) | https://example.com?key=foo&bar |
| 変更後(LIFF v2.31.1 以降) | https://example.com?key=foo?bar |
値の中の ? が、これまでは & に置き換えられていました。変更後は ? のまま届きます。
変更前の形を普通の方法で読むと、key の値は foo になり、bar という名前の空のパラメータが1つ増えます。変更後は、key の値が foo?bar になります。同じ URL でアクセスしても、アプリが受け取る値が変わるということです。
影響を受けるアクセス方法
変更の対象は、LIFF アプリを LIFF ブラウザで開いた場合です。外部ブラウザで開いた場合は変わりません。
? をパーセントエンコードして ?key=foo%3Fbar のように渡している場合も、開き方によっては影響があります。
| アクセス方法 | 変更前(現在) | 変更後(LIFF v2.31.1 以降) |
|---|---|---|
| iOS 端末で LINE アプリ以外からアクセス | ?key=foo&bar | ?key=foo?bar |
| iOS 端末で LINE アプリからアクセス | ?key=foo%3Fbar | 変更なし |
| Android 端末でアクセス | ?key=foo%3Fbar | 変更なし |
「iOS 端末で LINE アプリ以外から」とは、たとえば標準カメラで QR コードを読んだ場合や、メールやメモのリンクをタップした場合です。エンコードしていても安全とは限らない、という点に注意が必要です。
なぜ「?」が問題になるのか:LIFF URL の 2 段階のリダイレクト
1次リダイレクトと2次リダイレクト
LIFF URL(https://liff.line.me/{liffId}/...)を開くと、アプリの画面に届くまでに 2 回のリダイレクトが起きます。
- 1次リダイレクト: エンドポイント URL に移ります。このとき、LIFF URL に付けたパスやクエリパラメータは、
liff.stateという 1 つのクエリパラメータにまとめて入れられます。 - 2次リダイレクト: アプリの中で
liff.init()を呼ぶと、LIFF SDK がliff.stateの中身を取り出し、元のパスとクエリパラメータを付けた URL に移ります。
公式ドキュメントでは、1次リダイレクト先の URL が次のように示されています。エンドポイント URL が https://example.com/2020campaign/?key=value で、LIFF URL に path_A/?key1=value1#URL-fragment を付けてアクセスした場合です。
https://example.com/2020campaign/?key=value&liff.state=urlencoded(path_A/?key1=value1#URL-fragment)出典: LIFFアプリを開く | LINE Developers
LIFF URL に付けたパスやクエリパラメータが、liff.state の値としてまとめてエンコードされているのが分かります。liff.init() を呼ぶと、この中身が取り出されて、2次リダイレクト先の URL が組み立てられます。
liff.state とは何か。パラメータが「消える」ように見える理由
「LIFF でクエリパラメータが消える」「liff.state という見慣れないパラメータが付いている」という相談は、この仕組みが原因です。
1次リダイレクト先のページで location.search を読むと、目的のパラメータはなく、liff.state だけが見えます。パラメータが消えたのではなく、liff.state の中に入っています。liff.init() が終わると、元の形に戻ります。
LIFF v2.8.0 以降、liff.init() が完了するのは、2次リダイレクト先に移った時点です。
そのため、liff.init() の完了を待ってから URL を読むのが基本です。
公式の告知では、今回の変更は「LIFFブラウザでLIFF URLのクエリパラメータの値を2次リダイレクト先URLに復元する実装の一部が変わります」とされています。値に ? が入っていると、戻すときの扱いが変わるということです。
影響を受けるかどうかの見分け方
次のどれにも当てはまらなければ、今回の変更で困ることはほぼありません。
URL を値として渡しているパラメータ
いちばん影響を受けやすいのは、URL そのものをパラメータの値にしている場合です。公式の告知でも、例として挙げられています。
https://liff.line.me/{liffId}/login?return_url=https://example.com/cart?item=1return_url の値の中に ? があります。変更前は return_url=https://example.com/cart と item=1 に分かれて届き、変更後は return_url=https://example.com/cart?item=1 として届きます。
どちらか一方の形を前提に「分かれた item を拾ってつなぎ直す」ような処理を書いている場合、変更後に動かなくなります。
liff.state を自前で読み取っている処理
liff.init() の前に値が必要なときは、liff.state を自分で読み取る実装がよく使われます。ローディング画面の出し分けや、初期化の前に設定を読み込む場合などです。
const state = new URLSearchParams(location.search).get("liff.state");
const params = new URL("https://dummy" + state).searchParams;この書き方自体は問題ありません。値に ? を含まないパラメータだけを使っていれば、変更の前後で結果は変わりません。値に ? が入り得る場合は、変更の前後で読み取れる値が変わります。
当社が運用する LIFF アプリで確認した結果
当社が開発・運用している LIFF アプリのうち、liff.state を自前で読み取っている 3 本を確認しました。
- 3 本とも、クエリパラメータの値は英数字の ID だけで、
?や URL は入っていませんでした。 - そのため、実際に使っている値では結果は変わらない見込みです。
- 1 本は、エンドポイント URL 自体にクエリ(
?v=...)を付けていました。この場合、現在の LIFF SDK はliff.stateの中の?をすべて&に置き換えてから 2次リダイレクト先を組み立てます。告知はこのケースに触れていないため、10/7 以降も同じ動きになるかは、リリース後に確認する予定です。
確認は、各アプリの読み取り処理を関数として取り出し、変更前・変更後・%3F の 3 つの形の URL を入れて結果を比べる方法で行いました。同じ方法で、お手元のアプリも確認できます。
SDK のバージョンを固定していれば影響を受けないか
告知は対象を「LIFF v2.31.1 以降」と書いていますが、npm や CDN の固定パスで SDK のバージョンを固定しているアプリがどうなるかは書かれていません。
当社で現行の SDK(v2.31.0)のソースを確認したところ、告知の「変更前」のように、エンドポイント URL にクエリがないのに ? を & に置き換える処理は、SDK の中にはありませんでした。つまりこの置き換えは、SDK に値が渡る前に起きています。
そのため、SDK を固定していても影響を受ける可能性があります。「固定しているから大丈夫」とは考えず、次の章のように、変更の前後どちらの形でも動く実装にしておくのが確実です。
なお、CDN のエッジパス(https://static.line-scdn.net/liff/edge/2/sdk.js)で SDK を読み込んでいる場合は、常に最新の SDK が使われるため、v2.31.1 のリリース(10/7 予定)と同時に切り替わります。
出典: LIFF SDKのバージョニングポリシー | LINE Developers
変更前・変更後のどちらでも動く書き方
liff.init() の完了を待ってから読む
値が英数字の ID だけなら、liff.init() の完了を待ってから location.search を読めば十分です。
import liff from "@line/liff";
await liff.init({ liffId: "1234567890-AbcdEfgh" });
// liff.init() が完了した時点で、2次リダイレクト先の URL にいる
const params = new URLSearchParams(window.location.search);
const areaCode = params.get("area_code");URLSearchParams は + を空白に変換する点だけ注意してください。値に + が入る場合は、次の節のパーサーを使います。
値に URL を入れるときは、ID か base64url にする
値に ? を入れなければ、今回の変更は関係なくなります。根本的な対策は、URL を値に入れないことです。
- ID で持つ。
?return=cartのように渡し、アプリ側で ID を遷移先に変換します。値がA-Z a-z 0-9 - _だけなら、今回の変更にもエンコードの揺れにも左右されません。 - どうしても URL を渡すなら base64url にする。
?&%+#=のどれも含まないので、どの経路でも形が変わりません。 encodeURIComponentだけでは足りない。告知のとおり、iOS で LINE アプリ以外から開くと%3Fがデコードされて届きます。
また、戻り先の URL を受け取る場合は、自分のサイト内のパスかどうかを必ず確認してください。確認しないと、外部サイトへ飛ばされる入口(オープンリダイレクト)になります。
/** 文字列を base64url(A-Z a-z 0-9 - _ のみ)にする。?, &, %, +, #, = を含まない */
export function toBase64Url(s: string): string {
const bytes = new TextEncoder().encode(s);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function fromBase64Url(s: string): string | null {
try {
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
} catch {
return null;
}
}
/**
* 受け取った戻り先を検証する。自サイト内のパスだけを許可し、
* 外部サイトへ飛ばされる(オープンリダイレクト)のを防ぐ。
*/
export function safeReturnPath(raw: string | null, origin: string): string {
if (!raw) return "/";
try {
const u = new URL(raw, origin);
return u.origin === origin ? u.pathname + u.search + u.hash : "/";
} catch {
return "/";
}
}
/** LIFF URL を組み立てる例 */
export function buildLiffUrl(liffId: string, path: string, returnUrl: string): string {
const url = new URL(`https://liff.line.me/${liffId}${path}`);
url.searchParams.set("r", toBase64Url(returnUrl));
return url.toString();
}変更の前後どちらの形でも同じ結果を返すパーサー
すでに URL を値に入れて運用していて、すぐに ID へ切り替えられない場合のためのパーサーです。変更前(& に置き換わった形)、変更後(? のままの形)、%3F のままの形の、どれを受け取っても同じ結果を返します。
考え方は、「アプリが受け取るキーの一覧」をあらかじめ渡しておき、一覧にない断片は直前の値の続きとみなしてつなぎ直す、というものです。liff.init() の前(liff.state の中)でも後(location.search)でも同じ結果になります。
/**
* LIFF URL のクエリパラメータを、LIFF の仕様変更(2026-10-07 / LIFF v2.31.1)の
* 前後どちらでも同じ結果で読み取るためのユーティリティ。
*
* 値の中の「?」は、環境によって次の3通りの形で届く。
* A. 「&」に置き換わっている ?key=foo&bar (変更前の LIFF ブラウザなど)
* B. 「?」のまま ?key=foo?bar (変更後の LIFF ブラウザ)
* C. 「%3F」のまま ?key=foo%3Fbar (外部ブラウザ、Android など)
* このパーサーは A/B/C を同じ結果(key = "foo?bar")にそろえる。
*
* そのために「アプリが受け取るキーの一覧」を渡してもらう。
* 一覧にないキーの断片は、直前の値の続きとみなして連結する。
*/
export interface LiffQuerySchema {
/** アプリが受け取るキー。これ以外のキーは結果に含めない */
keys: readonly string[];
/**
* 値に URL を入れるキー。後ろに続く断片(y=1 など)も値の一部として連結する。
* 1つ目の断片は「?」、2つ目以降は「&」でつなぐ。
*/
urlKeys?: readonly string[];
}
/** 不正な %xx が含まれていても例外にしない decodeURIComponent。「+」は空白にしない */
function safeDecode(s: string): string {
try {
return decodeURIComponent(s);
} catch {
return s;
}
}
/**
* クエリ文字列(先頭の ? はあってもなくてもよい)を読み取る。
* 「+」は空白に変換せず、そのまま「+」として扱う
* (LIFF サーバーは「+」を %2B に変換して渡すため、それに合わせる)。
*/
export function parseLiffQuery(
query: string,
schema: LiffQuerySchema,
): Record<string, string> {
const keys = new Set(schema.keys);
const urlKeys = new Set(schema.urlKeys ?? []);
const result: Record<string, string> = {};
// フラグメント(#以降)はクエリではないので落とす
const hashAt = query.indexOf("#");
const q = (hashAt >= 0 ? query.slice(0, hashAt) : query).replace(/^\?/, "");
// 「&」と「?」の両方で区切る。区切りが元々どちらだったかは後で復元する
const tokens = q.split(/[?&]/).filter((t) => t.length > 0);
let current: string | null = null; // 直前に値を入れたキー
for (const token of tokens) {
const eq = token.indexOf("=");
const name = safeDecode(eq >= 0 ? token.slice(0, eq) : token);
const value = eq >= 0 ? safeDecode(token.slice(eq + 1)) : "";
if (keys.has(name)) {
// 既知のキー → 新しいパラメータの始まり
result[name] = value;
current = name;
continue;
}
if (name.startsWith("liff.")) {
// liff.state / liff.referrer など LIFF が付けるパラメータは値の一部にしない
current = null;
continue;
}
if (current !== null && urlKeys.has(current)) {
// URL を入れるキー → 後続の断片は URL のクエリの一部
const sep = result[current].includes("?") ? "&" : "?";
result[current] += sep + safeDecode(token);
continue;
}
if (current !== null && eq < 0) {
// 「=」のない断片(foo?bar の bar)→ 値の中にあった「?」の後ろとみなす
result[current] += "?" + safeDecode(token);
continue;
}
// 知らないキー(エンドポイント URL の ?v=... など)→ 無視する
current = null;
}
return result;
}
/**
* location.search から liff.state の値だけを取り出す。
* URLSearchParams は「+」を空白に変えるため使わない。
*/
export function getRawLiffState(search: string): string | null {
const q = search.replace(/^\?/, "");
for (const pair of q.split("&")) {
if (pair.startsWith("liff.state=")) {
return safeDecode(pair.slice("liff.state=".length));
}
}
return null;
}
/**
* liff.init() の前でも後でも同じ結果を返す入口。
* - 1次リダイレクト先(liff.init() 前): liff.state の中のクエリを読む
* - 2次リダイレクト先(liff.init() 後): location.search をそのまま読む
*/
export function readLiffQuery(
location: { search: string },
schema: LiffQuerySchema,
): Record<string, string> {
const state = getRawLiffState(location.search);
if (state === null) return parseLiffQuery(location.search, schema);
// liff.state は "/path?key=value#hash" の形。最初の ? より後ろがクエリ
const qAt = state.indexOf("?");
return qAt >= 0 ? parseLiffQuery(state.slice(qAt), schema) : {};
}使い方はこうです。
const schema = { keys: ["area_code", "return_url"], urlKeys: ["return_url"] };
const early = readLiffQuery(window.location, schema); // liff.init() の前
await liff.init({ liffId: "1234567890-AbcdEfgh" });
const after = readLiffQuery(window.location, schema); // liff.init() の後。early と同じになるNode.js で 32 通りの入力を試し、すべて期待どおりの結果になることを確認しました。主なものを抜粋します。
| 入力(location.search) | 形 | 結果 |
|---|---|---|
?key=foo&bar | 変更前 | {"key":"foo?bar"} |
?key=foo?bar | 変更後 | {"key":"foo?bar"} |
?key=foo%3Fbar | %3F のまま | {"key":"foo?bar"} |
?return_url=https://a.example/x&y=1&area_code=abc | 変更前 | {"return_url":"https://a.example/x?y=1","area_code":"abc"} |
?return_url=https://a.example/x?y=1&area_code=abc | 変更後 | {"return_url":"https://a.example/x?y=1","area_code":"abc"} |
?liff.state=%3Fkey%3Dfoo%3Fbar | liff.init() の前 | {"key":"foo?bar"} |
?v=2&key=foo&bar | エンドポイントにクエリあり | {"key":"foo?bar"} |
?q=a+b | + を含む | {"q":"a+b"} |
このパーサーの限界も書いておきます。? を & に置き換えた時点で情報が失われているため、どんなパーサーでも完全には元に戻せません。
- 値に
?と=の両方が入り得るキー(URL など)は、urlKeysに入れておく必要があります。入れないと、%3Fの形のときだけ結果が変わります。 - 値の URL のクエリに、アプリが受け取るキーと同じ名前が入っていると、そこで値が切れます。
あくまで移行までのつなぎと考え、根本的には ID か base64url に切り替えるのがおすすめです。
リリース前後の確認手順
10/7 より前に今の結果を記録しておき、10/7 以降に同じ条件で開き直して比べます。
1. 開き方を洗い出す
告知の表に合わせて、次の経路をそろえます。
| # | 端末 | 開き方 |
|---|---|---|
| 1 | iOS | LINE のトークに貼ったリンクをタップ |
| 2 | iOS | 標準カメラで QR コードを読む/メールやメモのリンクをタップ(今回いちばん変わる経路) |
| 3 | Android | LINE のトークに貼ったリンクをタップ |
| 4 | Android | カメラで QR コードを読む/Chrome から開く |
| 5 | PC など | 外部ブラウザで開く(変わらないことを確かめる対照) |
2. 試す URL を用意する
値だけを変えて、同じ画面を開きます。
?key=abc(基準)?key=foo?bar(値に?)?key=foo%3Fbar(%3F)?return_url=https://a.example/x?y=1&z=2(値に URL)?q=a+b%2Bc(+)
3. 毎回、同じ項目を記録する
liff.init() の前後で URL を記録するコードを、検証用のビルドに入れておくと比べやすくなります。
// liff.init() より前に実行する。location.hash にはアクセストークンが入るので記録しない
const firstSearch = location.search; // 1次リダイレクト先(liff.state を含む)
await liff.init({ liffId });
console.table({
sdk: liff.getVersion(), // SDK のバージョン(2.31.0 → 2.31.1 の切り替わり)
line: liff.getLineVersion(), // LINE アプリのバージョン
os: liff.getOS(),
inClient: liff.isInClient(), // LIFF ブラウザで開いたか
firstSearch,
secondSearch: location.search, // 2次リダイレクト先
});4. 10/7 以降に比べる
- アプリが読み取った値が、すべての開き方で同じになっているか
- 値が途中で切れていたり、
barのような余計なパラメータが増えていたりしないか - SDK を固定したビルドも用意しておくと、固定していても影響を受けるかどうかを実際に確かめられます
まとめ
- 2026 年 10 月 7 日の LIFF v2.31.1 で、LIFF URL のクエリパラメータの値に含まれる
?が、&に置き換えられなくなります。 - 影響を受けるのは、LIFF ブラウザで開いた場合です。特に、値に URL を入れている場合と、
liff.stateを自前で読み取っている場合です。 - 値が英数字の ID だけなら、
liff.init()の完了を待ってから読めば影響はありません。 - SDK のバージョンを固定していても、影響を受ける可能性があります。変更の前後どちらの形でも動く実装にしておくのが確実です。
- 根本的な対策は、URL を値に入れず、ID か base64url で渡すことです。
当社では、LIFF アプリ・LINEミニアプリの開発に加えて、既存アプリの保守や、今回のような仕様変更への対応も承っています。「自社のアプリが影響を受けるか分からない」という段階からでも、お問い合わせはこちらよりお気軽にご相談ください。
この検証を行ったのは 株式会社Amanity です。 自社で手を動かして技術を確かめたうえで、モバイルアプリ・LINEアプリ・Webアプリの受託開発を行っています。
開発について相談する