Amanity
LIFFのクエリパラメータ、10/7の仕様変更で何が変わる? liff.stateと2次リダイレクトの仕組みから対応方法まで
ブログ›エンジニア向け2026-09-25約16分

LIFFのクエリパラメータ、10/7の仕様変更で何が変わる? liff.stateと2次リダイレクトの仕組みから対応方法まで

対象読者: LIFF アプリ・LINEミニアプリを開発・保守しているエンジニア

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 回のリダイレクトが起きます。

LIFF URL を開いてからアプリにパラメータが届くまでの流れ。LIFF URL を開く → 1次リダイレクト(エンドポイント URL に移り、パラメータは liff.state の中)→ liff.init()(SDK が liff.state を取り出して URL に戻す)→ 2次リダイレクト(ここで「?」の扱いが変わる。変更前は ?key=foo&bar、変更後は ?key=foo?bar)
LIFF URL を開いてからアプリにパラメータが届くまでの、2 段階のリダイレクト
  1. 1次リダイレクト: エンドポイント URL に移ります。このとき、LIFF URL に付けたパスやクエリパラメータは、liff.state という 1 つのクエリパラメータにまとめて入れられます。
  2. 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 を付けてアクセスした場合です。

text
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次リダイレクト先に移った時点です。

出典: リリースノート | LINE Developers

そのため、liff.init() の完了を待ってから URL を読むのが基本です。

公式の告知では、今回の変更は「LIFFブラウザでLIFF URLのクエリパラメータの値を2次リダイレクト先URLに復元する実装の一部が変わります」とされています。値に ? が入っていると、戻すときの扱いが変わるということです。

影響を受けるかどうかの見分け方

次のどれにも当てはまらなければ、今回の変更で困ることはほぼありません。

URL を値として渡しているパラメータ

いちばん影響を受けやすいのは、URL そのものをパラメータの値にしている場合です。公式の告知でも、例として挙げられています。

text
https://liff.line.me/{liffId}/login?return_url=https://example.com/cart?item=1

return_url の値の中に ? があります。変更前は return_url=https://example.com/cart と item=1 に分かれて届き、変更後は return_url=https://example.com/cart?item=1 として届きます。

どちらか一方の形を前提に「分かれた item を拾ってつなぎ直す」ような処理を書いている場合、変更後に動かなくなります。

liff.state を自前で読み取っている処理

liff.init() の前に値が必要なときは、liff.state を自分で読み取る実装がよく使われます。ローディング画面の出し分けや、初期化の前に設定を読み込む場合などです。

typescript
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 を読めば十分です。

typescript
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 を値に入れないことです。

  1. ID で持つ。?return=cart のように渡し、アプリ側で ID を遷移先に変換します。値が A-Z a-z 0-9 - _ だけなら、今回の変更にもエンコードの揺れにも左右されません。
  2. どうしても URL を渡すなら base64url にする。? & % + # = のどれも含まないので、どの経路でも形が変わりません。
  3. encodeURIComponent だけでは足りない。告知のとおり、iOS で LINE アプリ以外から開くと %3F がデコードされて届きます。

また、戻り先の URL を受け取る場合は、自分のサイト内のパスかどうかを必ず確認してください。確認しないと、外部サイトへ飛ばされる入口(オープンリダイレクト)になります。

typescript
/** 文字列を 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)でも同じ結果になります。

typescript
/**
 * 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) : {};
}

使い方はこうです。

typescript
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%3Fbarliff.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. 開き方を洗い出す

告知の表に合わせて、次の経路をそろえます。

#端末開き方
1iOSLINE のトークに貼ったリンクをタップ
2iOS標準カメラで QR コードを読む/メールやメモのリンクをタップ(今回いちばん変わる経路)
3AndroidLINE のトークに貼ったリンクをタップ
4Androidカメラで QR コードを読む/Chrome から開く
5PC など外部ブラウザで開く(変わらないことを確かめる対照)

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 を記録するコードを、検証用のビルドに入れておくと比べやすくなります。

typescript
// 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アプリの受託開発を行っています。

開発について相談する