Learn Pinia - Security & Best Practice
Episode 15 of 23

Learn Pinia - Security & Best Practice

Healthy state management also means secure state. This episode covers avoiding the persistence of tokens and secrets, choosing the right storage, sanitizing API data before it enters a store, and best practices for state classification, single responsibility per store, and role-based access control.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Stores hold an app's important data — including sensitive data. Common mistakes: storing tokens, storing entire API responses without sanitization, or persisting information that shouldn't survive. This episode changes those habits.

Episode 15 covers Pinia security and best practices: avoiding the persistence of secrets, choosing secure storage, sanitizing data before it enters a store, and the principles of state classification, single responsibility, and role-based access control.

Sensitive Data and Secure Storage

The first rule: never persist tokens and secrets to localStorage. localStorage can be read by any JavaScript running on the page, making it vulnerable to XSS:

JSUnsafe pattern
// DON'T: token stored in localStorage
persist: {
  key: 'auth',
  storage: localStorage,
}
 
// SAFER: token lives in memory (store without persist)
export const useAuthStore = defineStore('auth', {
  state: () => ({ token: null as string | null }),
})

Keep token only in state (memory) and give it a short lifetime via a refresh token on the server side. If persistent storage is truly needed, consider a server-controlled mechanism like an httpOnly cookie.

Choosing Storage by Data Nature

Not all data is dangerous — classify it first before deciding where to store it:

  • UI preferences (theme, language): safe in localStorage via persist.
  • Active session: keep in memory; let the server-side refresh token extend the session.
  • Session-sensitive data (profile, balance): don't persist without encryption and a security review.
JSExample of choosing storage per store
persist: {
  key: 'app-preferences',
  storage: localStorage,
}

persist: { storage: localStorage } is indeed the most convenient, but whether it's appropriate depends on the state's contents. Start with the question: what happens if this data is read by someone else using the same device?

Sanitizing Data from APIs

API responses aren't always clean. Before data enters a store, validate it and pick only the fields you need:

JSSanitize an API response
actions: {
  async fetchProfile() {
    const res = await fetch('/api/profile').then((r) => r.json())
    this.profile = {
      id: String(res.id ?? ''),
      name: typeof res.name === 'string' ? res.name : '',
      role: res.role === 'admin' ? 'admin' : 'user',
    }
  },
}

this.profile = {...} only stores the expected fields, with type validation and allowed values. This pattern prevents unexpected API data from polluting state and breaking rendering.

Limit the Fields Stored

The less data in the store, the smaller the surface area for problems:

  • Only store fields that are actually rendered or used by logic.
  • Don't copy entire API objects without a process — for example, don't just write this.user = res.
  • Create a centralized mapping function so validation is consistent across all stores.

These small sanitization functions are an investment that pays off when the API changes its structure.

State Classification

Separate state by its nature so its security treatment is clear:

  • UI state: open modal, theme, sidebar — may be persisted, not sensitive.
  • Global state: sessions, preferences — handle with care.
  • Server state: data from APIs — manage through Colada or Vue Query (episode 14).

This classification also helps determine what may be persisted and what must stay in memory.

Single Responsibility and Access Control

Architecture best practices that intersect with security:

  • One store handles one domain — don't mix tokens into the cart store.
  • Don't expose sensitive data through getters that any component can access.
  • Do role-based access control in actions, not only in the template:
JSRole-based guard in an action
actions: {
  async deleteUser(id: string) {
    const auth = useAuthStore()
    if (auth.user?.role !== 'admin') {
      throw new Error('Forbidden')
    }
    await fetch(`/api/users/${id}`, { method: 'DELETE' })
  },
}

auth.user?.role !== 'admin' stops sensitive operations at the logic level, not just by hiding a button in the UI. Remember: client-side prevention doesn't replace server-side authorization — this is the first line of defense, not the only one.

Warning

All data on the client can be read by the user. Never put secrets like passwords or API keys in a store, no matter what form of persistence you use.

Closing

Episode 15 equips you with production-ready security and best practices. You now know how to avoid persisting secrets, choose secure storage, filter data from APIs, and apply state classification, single responsibility, and role-based access control.

Key takeaways:

  • Tokens and secrets must never be persisted to localStorage.
  • Keep tokens in memory; use a server-side refresh token.
  • Sanitize API responses before they enter a store.
  • Classify state: UI, global, and server.
  • One store per domain; don't mix sensitive data.
  • Apply access control in actions, not only in the template.

In the next episode, episode 16, we'll discuss a TypeScript deep dive — typing stores that are inferred automatically, using generics in defineStore, typing plugins with PiniaCustomProperties, and type inference for Setup stores. Pinia will become your best TypeScript friend.