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
- The page reads the
hashfrom the URL and calls the API to resolve thelineLiffIdfor that LINE OA. - The current URL is stored in
useAppStore.redirectUrias the destination to return to after a successful login. 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 viahistory.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 inuseUserStore.
- Calls
- Consumers of the hook call
getIdToken()to attach the token to their API request headers. - If the user returns from LINE and is still not logged in, the hook sets
loginFailedalong withisLiffReady, 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:
| Key | Purpose |
|---|---|
liff-login-attempted | Allows only one automatic login attempt per tab. |
liff-expired-relogin | Allows 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 callsliff.logout(), whileuseRedirect()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 towindow.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/:hashreturnslineLiffId,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, andcloseWindow. - LINE OA service (
src/service/line-oa.service.ts) is the source of each OA's LIFF configuration. - Zustand stores —
useAppStoreholds the redirect URI and LIFF ID;useUserStoreholds 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
hashor 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)
- Load the OA with active status that has not been deleted — not found → 401 with the message
Invalid channel. - An OA with no LINE Login channel configured → 401 with
LINE Login not configured for this channel. - 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.
- 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 typeguest. - 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)
- Neither token present → 401
No authentication token provided. - The OA has no primary channel → immediate 401 (fail closed — with nothing to bind against, nothing is accepted).
- If
x-liff-tokenis present, try verifying it as an ID token against each bound channel in turn (primary first, then the form channel). - If that still fails, try interpreting it as an access token — using both
x-liff-access-tokenandx-liff-token, since some clients send an access token in thex-liff-tokenslot. 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. - 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 channeleven 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.