The identity cookie stores only token / user_id / has_login / id. Other profile fields use encrypted user-profile localStorage. Client-side info merges both parts; SSR exposes identity fields only. setInfo replaces the full profile, splitting every login and automatic re-login before writing the cookie. setToken preserves profile fields. Both expireLogin and logout clear 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 useAuthReady API 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.auth in the diagram is the runtime key: the plugin actually reads runtimeConfig.public.swiftWebFramework.auth. What you write at config time in swift.config.json / nuxt.config.ts is swiftConfig.auth — framework injects it at build time into the runtime runtimeConfig.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, neither POST /login/device in the diagram happens any more. With no credentials, or when credential renewal fails, the flow ends as a guest (no token) and autoLogin() returns false; username / QR login and credential renewal are unaffected. The gate lives only in autoLogin(), so the systemInfo fallback and the 401 / 2002 re-login honor it as well (fw#412).

Behavior across rendering modes

StepSSRISR (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 → retryserver 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:

  1. server cookie has no tokenInfo / deviceId
  2. server request() forwards an empty token → backend responds as fully anonymous
  3. SSR HTML renders the anonymous version (no nickname / VIP badge / personalization)
  4. Hydration → auth.client.ts runs → device login obtains the device token
  5. 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 useAsyncData callbacks (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):

  1. Clear user state first: expireLogin() clears tokenInfo and profile data (isLoggedIn reactively 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.
  2. 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.
  3. 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.