The identity cookie stores only
token / user_id / has_login / id. Other profile fields use encrypteduser-profilelocalStorage. Client-sideinfomerges both parts; SSR exposes identity fields only.setInforeplaces the full profile, splitting every login and automatic re-login before writing the cookie.setTokenpreserves profile fields. BothexpireLoginandlogoutclear the profile. Legacy full cookies migrate on the client. If nginx already rejects the request, clear the affected site identity cookie first: page scripts cannot bypass an entry-point 400.
Auto-Login + Token-Refresh Flow
For people who want the whole picture. If you just need to pick a strategy for a page, see Rendering Quick Guide. For the
useAuthReadyAPI only, see useAuthReady.This doc describes the framework v3 auth design (device + token established entirely client-side, framework#44).
One-liner
Identity is established entirely client-side; token expiry is auto-refreshed at the request interceptor layer.
The server never initiates login, never writes the device id — this is the foundation of safe ISR caching. Any user state on the server side risks being baked into the ISR cache and broadcast to every visitor.
Full sequence diagram
swiftWebFramework.authin the diagram is the runtime key: the plugin actually readsruntimeConfig.public.swiftWebFramework.auth. What you write at config time inswift.config.json/nuxt.config.tsisswiftConfig.auth— framework injects it at build time into the runtimeruntimeConfig.public.swiftWebFramework.auth. Both refer to the same switch across two layers — config-time (swiftConfig.auth) vs runtime (swiftWebFramework.auth); this diagram describes the post-hydration runtime self-check, hence the latter.
Turning off device login: with
swiftConfig.auth.deviceLogin: false, neitherPOST /login/devicein the diagram happens any more. With no credentials, or when credential renewal fails, the flow ends as a guest (no token) andautoLogin()returnsfalse; username / QR login and credential renewal are unaffected. The gate lives only inautoLogin(), so the systemInfo fallback and the 401 / 2002 re-login honor it as well (fw#412).
Behavior across rendering modes
| Step | SSR | ISR (prerender) | CSR (ssr: false) |
|---|---|---|---|
system.server.ts (public /system/info; channel-code planting lives in system.client.ts, fw#212) | ✅ per request | ✅ once (cached) | ❌ |
auth.client.ts plugin (getDeviceId + auto-login) | ❌ (server render phase only) | ❌ (server render phase only) | ✅ once after hydration |
request() internal await useAuthReady() | ❌ (no-op) | ❌ (no-op) | ✅ |
Token expiry → autoLogin → retry | server does not trigger (uses cookie token as-is; if expired, returns as unauthenticated) | same as SSR | ✅ intercepted + retried |
login.global middleware guarding guardedPaths | ✅ (no user state; unauth → redirect to /login) | n/a | ✅ |
Key insight: auth.client.ts is a client plugin — it runs after hydration in all three rendering modes (the ❌ above only means it does not run during server rendering). From loginByDevice's perspective the three rendering modes are equivalent — device id + token are only established after client hydration. The SSR server forwards whatever token is already in the cookie (request.ts connects to backend directly), but never initiates login.
Limits and explicit trade-offs
⚠️ First-time visitors see an "anonymous flash" on SSR pages
When a never-visited user opens a ssr: true page:
- server cookie has no tokenInfo / deviceId
- server
request()forwards an empty token → backend responds as fully anonymous - SSR HTML renders the anonymous version (no nickname / VIP badge / personalization)
- Hydration →
auth.client.tsruns → device login obtains the device token - User-specific fragments (nickname / badge) suddenly appear after hydration → visual flash
This is an intrinsic trade-off of the ISR-first architecture — we trade "server never touches user state" for "every public page can be safely ISR-cached". Full reasoning in framework#44.
Mitigation idiom (mandatory on the business side):
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
const userStore = useUserStore()
const userReady = ref(false)
onMounted(async () => {
await useAuthReady()
userReady.value = true
})
</script>
<template>
<article>
<SectionPublic />
<ClientOnly>
<SectionUserBadges v-if="userReady" :info="userStore.info" />
<SkeletonBadges v-else />
</ClientOnly>
</article>
</template>
User-state fragments start as skeletons and fill in once login completes — visually a "load complete", not a "suddenly transformed".
⚠️ Token-expiry auto-retry only happens on the client
When the SSR server hits errorCode 2002, no onRelogin is triggered — because the server cannot re-establish a device identity (a re-login would write Set-Cookie into the response header, which an ISR cache would broadcast).
Consequence: server-side useFetch with expired token → backend returns as unauthenticated → SSR HTML renders anonymously. After hydration, the same useFetch re-fetched on the client triggers token refresh + retry.
Result: user perceives "login state briefly lost for ~1s after refresh, then returned". Another intrinsic v3 trade-off.
Business-side taboos (three layers: governance + lint + runtime)
Calling an identity-establishment API on the server pollutes ISR cache — different visitors get the same token / deviceId (security-grade incident). The framework guards at three layers:
1. Runtime guard
getDeviceId / autoLogin / loginByUsername / register / loginByQrcode / loginByDevice / expireLogin / logout and other state-mutating methods throw when import.meta.server — making violations explode immediately instead of silently corrupting cache. The last line of defense.
2. ESLint rule
@swift-web/framework/eslint plans to provide swift-web/no-server-user-mutation (not yet implemented) — scans <script setup> synchronous paths for userStore.autoLogin/.setInfo/... calls.
3. Standards governance rule
standards/rules/ssr-safety.md lists "client-only identity establishment" as a hard constraint, synced into every applier project's review rules.
Remember on the business side:
- ❌ Calling
useUserStore().autoLogin() / getDeviceId() / setInfo() / loginByUsername()in<script setup>synchronous paths - ❌ Calling the above in
useAsyncDatacallbacks (callbacks also run during SSR) - ✅
onMounted/ event handlers / inside<ClientOnly>— these are the legal moments for identity establishment
See Rendering Quick Guide § Anti-patterns for full anti-pattern list.
Login exhaustion: when the 2002 retry fails again
On the client, request() silently re-logins and retries once on errorCode 2002 (see Q3). If the retry still returns 2002, the login is "exhausted" — the token cannot be recovered even by re-login (e.g. the backend rotated its keys). Behavior on exhaustion since framework ≥ 0.107.0 (fw#302):
- Clear user state first:
expireLogin()clears tokenInfo and profile data (isLoggedInreactively becomes false, pages stop rendering as logged-in); deviceId (device identity), remembered credentials (autoLogin can still recover on next refresh), and channel codes are kept. - Then broadcast: the nuxtApp hook
swift-web:auth-expired(payload{ reason: 'relogin-exhausted' }), at most once per expiry episode, reset after a successful login. Listeners already see the logged-out state. - Still throws
ApiError(2002): caller try/catch semantics are unchanged.
With zero changes you already get "no more phantom logged-in state" (guards take over on the next navigation). For immediate guidance (login dialog / redirect), register a listener:
// app/plugins/auth-expired.client.ts
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('swift-web:auth-expired', () => {
useLoginDialog().open() // or navigateTo('/login')
})
})
SSR-side exhaustion neither clears state nor broadcasts (it stays a pure throw) — mutating cookies during SSR would be broadcast to every visitor via the ISR cache (see taboos above), and the throw itself is part of the "never bake an empty state into the cache" guardrail.
FAQ
Q1: What happens when auth: false?
auth.client.ts returns early, but still calls _resolveAuthReady(). The internal await useAuthReady() inside request() does not hang; requests go out anonymously. Suitable for backend-less projects like the docs site. See auth ModuleOptions.
Q2: Does the business side need to detect "auto-login failed"?
By default, no (a deliberate trade-off: prevent business from hanging if login fails). auth.client.ts wraps with try / finally to ensure _resolveAuthReady() is called even when login throws — requests go out anonymously and the backend treats them as unauthenticated.
If the business needs to explicitly know "I'm in anonymous mode", read useUserStore().isLoggedIn (meaningful only after hydration).
Q3: Does in-SPA token expiry auto re-login?
Yes. The mechanism is not in auth.client.ts (a one-shot plugin) but in the request.ts interceptor — when it sees errorCode === 2002, it awaits onRelogin() then retries with _retry=true. Zero awareness on the business side.
Q4: Why does internal framework login use frameworkInternalRequest() instead of request()?
To avoid deadlock: request() internally awaits useAuthReady(); useAuthReady() waits for login to complete. If login also went through request(), it would wait forever. See useAuthReady — Cooperation with request internals.
Q5: How is deviceId generated? How does the backend identify the same device?
Generated client-side via framework randomUuid() (uses crypto.randomUUID in a secure context; falls back to getRandomValues hand-rolled UUID v4 in non-secure environments like LAN HTTP, avoiding an undefined-induced 500), written to a cookie (10-year lifetime). Read from cookie on subsequent visits. Different browser / private mode / cleared cookie = new deviceId.
The backend does not rely on weak signals like IP / UA — it strictly trusts the deviceId cookie.
Related docs
- useAuthReady — composable API details and business usage
- userInfo Store — auth state / token / deviceId data structure
- Route Guards —
guardedPaths/guestPathsconfiguration - Request System —
request()API + response unwrap + error handling - Rendering Quick Guide — business page → strategy decision matrix
- auth ModuleOptions —
auth: falseto disable site-wide - Source:
framework/src/runtime/plugins/auth.client.ts+utils/request.ts+store/user.ts