Skip to main content

LINE Login via LIFF

Overview

This mechanism is the heart of the entire client-facing web app. It takes a user from "just opened a link inside the LINE app" to "holds a LINE ID token that the API can verify".

The system does not issue its own JWT, uses no session cookie, and never sets an Authorization header. Every client-side API endpoint authenticates through the x-liff-token HTTP header carrying the LINE ID token, and some routes additionally attach x-liff-access-token as a fallback.

All of the logic is centralized in the useLiffAuth hook (src/hooks/use-liff-auth.ts), which guards against a long list of edge cases that once produced blank screens or infinite login loops. Anyone modifying this area should understand each guard before changing it.

Business Flow

  1. The page reads the hash from the URL and calls the API to resolve the lineLiffId for that LINE OA.
  2. The current URL is stored in useAppStore.redirectUri as the destination to return to after a successful login.
  3. useLiffAuth(liffId) runs the following sequence:
    • Calls liff.init() with the resolved LIFF ID.
    • If not logged in and the user is inside the LINE app, they are sent to the app's LIFF URL. If they are outside the LINE app, liff.login() is called with the stored redirect URI.
    • If already logged in, the query parameters LINE appended (code, state, liffClientId, liffRedirectUri, liff.state) are stripped from the address bar via history.replaceState.
    • Reads the ID token through liff.getIDToken(), retrying up to three times with a 300 ms delay, because the token may not be available immediately after init.
    • Checks the expiry claim in the JWT payload. Crucially, if the value cannot be read, the token is not treated as expired — this protects against base64url decoding failures.
    • If the token really is expired, the app logs out and logs back in exactly once, preventing devices with a skewed clock from looping forever.
    • Fetches the profile via liff.getProfile() and stores it in useUserStore.
  4. Consumers of the hook call getIdToken() to attach the token to their API request headers.
  5. If the user returns from LINE and is still not logged in, the hook sets loginFailed along with isLiffReady, so the screen can show a "Sign in again" button instead of spinning indefinitely.

Guarding against oversized URIs

On every login, LINE wraps the entire redirect URI inside a new liff.state layer. If a loop occurs, the URL nests repeatedly until it exceeds roughly 8 KB, at which point nginx or Next.js responds with HTTP 414.

The flattenRedirectUri() helper solves this by extracting the real query parameters from liff.state exactly one level deep, then discarding liff.state and all leftover OAuth parameters.

Two-layer sessionStorage guards

Two sessionStorage keys cap how many automatic logins may occur per tab:

KeyPurpose
liff-login-attemptedAllows only one automatic login attempt per tab.
liff-expired-reloginAllows only one forced re-login triggered by an expired token.

Both keys are scoped to the LIFF ID of the app in question.

Key Screens & Components

Core hooks

  • useLiffAuth(liffId) (src/hooks/use-liff-auth.ts) is the central entry point. It returns the user profile, an ID-token getter, a LIFF readiness flag, and a login-failure flag. Internally it includes helpers for reading token expiry and stripping OAuth parameters.
  • useLiffInit(liffId) (src/hooks/use-liff-init.ts) only initializes LIFF and does not force a login. It is used on pages that must never bounce the user away, such as the post-submission thank-you page.
  • useFetchGetLineOaByHash(hash) (src/hooks/use-fetch-line-oa-by-hash.ts) turns an OA hash into its LIFF configuration.
  • useAppLogout() clears every store and calls liff.logout(), while useRedirect() sends the user back to the stored redirect URI.

Library-level helpers

  • flattenRedirectUri() (src/hooks/liff-redirect.ts) handles nested URLs and defines the list of OAuth parameters to discard.
  • closeLiff() (src/lib/liff-close.ts) closes the LIFF window, falling back to window.close().
  • liffSameOriginPath() (src/lib/liff-same-origin.ts) rewrites LIFF links that point back at this same app into same-origin paths, preventing stacked LIFF windows.

Wrapper for login-gated features

  • useBulletinAuth (src/app/[hash]/bulletin/hooks/) bundles the full chain from hash resolution through to header assembly, and is reused by the bulletin board, loyalty card, and staff tooling features.

Related endpoint

  • GET /line-oa/get-by-hash/:hash returns lineLiffId, formLiffId, botBasicId, and the OA's display name and image.

The behaviour of flattenRedirectUri() is covered by unit tests at src/hooks/__tests__/liff-redirect.test.ts.

Dependencies

  • @line/liff v2 is the core SDK, exercising a wide surface: init, isLoggedIn, login, logout, getIDToken, getAccessToken, getProfile, isInClient, isApiAvailable, scanCodeV2, and closeWindow.
  • LINE OA service (src/service/line-oa.service.ts) is the source of each OA's LIFF configuration.
  • Zustand storesuseAppStore holds the redirect URI and LIFF ID; useUserStore holds the user profile (see App Shell & Core Providers).
  • Every feature that needs to know who the user is depends on this hook: Form Filling, Bulletin Board, Loyalty Card, Friend-add Tracking Campaign, Appointment Booking, and Public Menu.
  • Note: a few pages, such as the friend-add campaign and appointment booking, call liff.init() directly instead of using the hook, because they need custom control over the order in which the token and profile are obtained.

Backend Details (Client API)

The backend has no JWT of its own either — no sessions, and it never reads the Authorization header. Whenever the web app sends x-liff-token, client-api verifies that token against the LINE Platform live; it never trusts a token by its shape or by decoding it locally.

The core idea: channel binding

This one concept explains nearly all of the backend's auth behaviour.

  • A valid token only proves that some LINE account exists. It does not prove that the account belongs to this tenant — anyone can create their own LINE Login channel and mint valid tokens from it.
  • So every flow must bind the token to the LINE Login channel of the OA resolved from the hash or token in the route before accepting it.
  • What this looks like from the web side: a token that works for one OA will not work for another, even for the same user with an unexpired token.

The three verification paths (they are not equivalent)

Path 1 — verify an ID token and auto-provision (forms, public menus, public content, tracking)

  1. Load the OA with active status that has not been deleted — not found → 401 with the message Invalid channel.
  2. An OA with no LINE Login channel configured → 401 with LINE Login not configured for this channel.
  3. Verify the ID token against the primary channel first. If that fails and the OA has a form LIFF on a different channel, retry against the form channel. This is why tokens obtained on a form page still work even though the LIFF ID is not the primary one.
  4. Look up the user by the (LINE user id, OA) pair — if none exists, one is created immediately as a guest, using the display name from the token claim or the default Guest User, Thai as the default language, followed status set, and user type guest.
  5. Return the user id and record for the endpoint to use.

Important consequence: a new user simply opening the page already has a side effect — their user row is created right then, without waiting for a form submission.

Path 2 — a channel-bound ladder (bulletin board and loyalty card)

  1. Neither token present → 401 No authentication token provided.
  2. The OA has no primary channel → immediate 401 (fail closed — with nothing to bind against, nothing is accepted).
  3. If x-liff-token is present, try verifying it as an ID token against each bound channel in turn (primary first, then the form channel).
  4. If that still fails, try interpreting it as an access token — using both x-liff-access-token and x-liff-token, since some clients send an access token in the x-liff-token slot. This step introspects the token first and requires its channel to be one of the bound channels; a mismatch is rejected outright. Only then is the profile fetched.
  5. Every failure returns 401 with the same message: Invalid or expired LIFF token.
  • Security note: the error messages are made deliberately identical — a caller cannot distinguish "bad token" from "valid token, wrong channel". The web app should therefore not try to infer the cause from the message; treat them all the same and prompt for a fresh login.
  • An empty channel id is not treated as "no restriction" — it never passes the check.

Path 3 — verify an access token directly, with no channel binding (appointment booking and friend-add campaign)

These two verify the access token directly with LINE for parity with the legacy system. Appointment booking has no channel binding at all, which makes it weaker than the other paths and is worth knowing. The friend-add campaign, when given x-liff-token, verifies it against the OA's primary channel directly (401 if the OA has no channel).

Access control for audience-restricted content

Content with an audience configured takes a verification path that differs from the norm in two deliberate ways: there is no fallback to the form channel (primary only), and failures are 403, not 401. The web app should surface these differently — 401 means "log in again", 403 means "you don't have access" and re-logging-in will not help.

Audience matching rules:

  • Content with no audience configured → open to everyone (public).
  • Content with an audience but the user belongs to none → 403 You do not have access to this content.
  • Both sides have audiences → allowed if at least one overlaps (OR, not AND).

Graceful degradation that keeps the web app working when verification fails

Some endpoints intentionally swallow verification failures instead of returning an error. This explains cases where a page renders fine but shows incomplete data with no error at all.

  • Public menus and content link listings: verification failure → only public items are returned (not a 401).
  • Tracking redirects: the click is recorded anonymously and the redirect proceeds as normal.
  • The "already submitted?" check for forms: verification failure → returns "not submitted", so the user sees an empty form instead of the already-submitted screen.

Edge cases worth knowing

  • The backend calls the LINE Platform on every verification, with a timeout. If LINE is slow, requests hang longer than usual — the cause is not the web app.
  • A disabled or deleted OA returns 401 Invalid channel even with a perfectly good token — it looks like an expired token, but logging in again does not help.
  • The ladder logic for the bulletin board and loyalty card is duplicated on purpose (no cross-domain imports), so small differences in auth behaviour between those two features are expected rather than bugs.
  • The endpoint the web app uses to turn a hash into LIFF configuration returns both the primary LIFF ID and the form LIFF ID, because those two values determine which channels the backend will accept a token from.