Choosing the Right Authentication Pattern
The correct auth architecture for a JavaScript application depends on one key question: does your app have a server? The answer determines where tokens live and therefore how secure your auth implementation can be.
| App Type | Recommended Pattern | Token Storage | PKCE |
|---|---|---|---|
| Next.js (App Router) | Server-side session via NextAuth.js v5 | HTTP-only cookie (server-managed) | ✅ Always |
| React SPA | BFF recommended; or PKCE + in-memory tokens | Memory (access token) + HTTP-only cookie (refresh token) | ✅ Always |
| Vue 3 SPA | Same as React SPA | Memory (access token) + HTTP-only cookie (refresh token) | ✅ Always |
localStorage. localStorage is accessible to all JavaScript on the page, making tokens trivially stealable via XSS. Store access tokens in memory and refresh tokens in HTTP-only cookies only.
Next.js App Router Authentication
Next.js 14+ App Router with React Server Components is the most secure JavaScript architecture for auth in 2026. Tokens never touch the browser — the Next.js server holds the session and injects only safe, non-sensitive session data into server components.
1. Install NextAuth.js v5
npm install next-auth@5
2. Configure the Ailacs Identity OIDC Provider
// auth.ts
import NextAuth from "next-auth";
import type { NextAuthConfig } from "next-auth";
export const config: NextAuthConfig = {
providers: [
{
id: "ailacs",
name: "Ailacs Identity",
type: "oidc",
issuer: "https://auth.ailacs.com",
clientId: process.env.AUTH_AILACS_CLIENT_ID,
clientSecret: process.env.AUTH_AILACS_CLIENT_SECRET,
checks: ["pkce", "state", "nonce"],
authorization: {
params: { scope: "openid profile email", response_type: "code" }
}
}
],
callbacks: {
async jwt({ token, account }) {
if (account) token.accessToken = account.access_token;
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
return session;
}
}
};
export const { handlers, auth, signIn, signOut } = NextAuth(config);
3. Protect Routes with middleware.ts
// middleware.ts (project root — runs on the Edge runtime)
export { auth as middleware } from "./auth";
export const config = {
matcher: [
"/dashboard/:path*",
"/account/:path*",
"/api/protected/:path*"
]
};
4. Access Session in Server Components
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session) redirect("/api/auth/signin");
return (
<div>
<h1>Welcome, {session.user?.name}</h1>
<p>Signed in as: {session.user?.email}</p>
</div>
);
}
Server components call auth() directly — no client-side hooks or context providers needed. Tokens are never serialised into client bundles.
React SPA Authentication
For a pure React SPA with no server, use react-oidc-context which wraps oidc-client-ts and implements the authorisation code flow with PKCE entirely in the browser. For high-security apps, pair it with a BFF proxy to keep tokens off the client entirely.
1. Install the Library
npm install oidc-client-ts react-oidc-context
2. Wrap Your App with AuthProvider
// main.tsx
import { AuthProvider } from "react-oidc-context";
const oidcConfig = {
authority: "https://auth.ailacs.com",
client_id: "your-react-spa-client-id",
redirect_uri: window.location.origin + "/callback",
response_type: "code",
scope: "openid profile email",
automaticSilentRenew: true
};
ReactDOM.createRoot(document.getElementById("root")!).render(
<AuthProvider {...oidcConfig}>
<App />
</AuthProvider>
);
3. Create a Protected Route Component
// components/ProtectedRoute.tsx
import { useAuth } from "react-oidc-context";
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const auth = useAuth();
if (auth.isLoading) return <div>Loading...</div>;
if (auth.error) return <div>Auth error: {auth.error.message}</div>;
if (!auth.isAuthenticated) { auth.signinRedirect(); return null; }
return <>{children}</>;
}
4. Use in Your Router
<Route path="/dashboard" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
Vue 3 Authentication
Vue 3 auth is best implemented with oidc-client-ts combined with a Pinia store for reactive auth state and vue-router navigation guards to protect routes.
1. Install oidc-client-ts and Pinia
npm install oidc-client-ts pinia
2. Create the Pinia Auth Store
// stores/auth.ts
import { defineStore } from "pinia";
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
const userManager = new UserManager({
authority: "https://auth.ailacs.com",
client_id: "your-vue-client-id",
redirect_uri: window.location.origin + "/callback",
response_type: "code",
scope: "openid profile email",
userStore: new WebStorageStateStore({ store: window.sessionStorage })
});
export const useAuthStore = defineStore("auth", {
state: () => ({
user: null as Awaited<ReturnType<typeof userManager.getUser>>
}),
getters: {
isAuthenticated: (state) => !!state.user && !state.user.expired,
accessToken: (state) => state.user?.access_token
},
actions: {
async init() { this.user = await userManager.getUser(); },
async login() { await userManager.signinRedirect(); },
async handleCallback() { this.user = await userManager.signinRedirectCallback(); },
async logout() { await userManager.signoutRedirect(); }
}
});
3. Add Navigation Guards
// router/index.ts
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: () => import("../views/Home.vue") },
{ path: "/dashboard", component: () => import("../views/Dashboard.vue"),
meta: { requiresAuth: true } },
{ path: "/callback", component: () => import("../views/Callback.vue") }
]
});
router.beforeEach(async (to) => {
const authStore = useAuthStore();
await authStore.init();
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
await authStore.login();
return false;
}
});
The BFF Pattern for High-Security SPAs
The Backend for Frontend (BFF) pattern is the most secure architecture for SPAs. A thin server-side proxy handles all OAuth flows and stores tokens server-side. The browser only holds an HTTP-only session cookie — tokens never touch client-side JavaScript.
- SPA performs PKCE flow in the browser
- Tokens stored in browser memory or sessionStorage
- XSS can potentially exfiltrate tokens from memory
- Silent renewal required on every page reload
- BFF performs PKCE flow on the server
- Tokens stored in server-side session (Redis or in-memory)
- Browser only holds an HTTP-only, Secure, SameSite=Strict cookie
- XSS cannot read tokens — they never reach the browser
For .NET, use Duende.BFF or the built-in ASP.NET Core OIDC + cookie authentication middleware as your BFF. Your React or Vue frontend then calls BFF API endpoints directly — the browser's session cookie is automatically included in every same-origin request.
Token Security Rules for JavaScript Apps
- NeverStore access or refresh tokens in
localStorage— XSS-accessible to all scripts on the page - NeverStore tokens in non-HTTP-only cookies — JavaScript-readable via
document.cookie - AlwaysUse PKCE (
code_challenge_method=S256) — required for all public clients; eliminates the need for a client secret - AlwaysValidate
stateandnonceparameters to prevent CSRF and token replay attacks - RecommendedUse short-lived access tokens (5–15 minutes) with automatic silent renewal via a hidden iframe or refresh token rotation
- RecommendedImplement the BFF pattern for applications that handle sensitive data, payments, or have high security requirements
- RecommendedEnforce a strict Content Security Policy (CSP) — the most effective defence against XSS in JavaScript apps