Skip to content

Authentication

Authentication answers the question of who sends a request. The course API of part 7 simulates a sign-in with the member number and no password. Even so, it shows the flow that a real sign-in has. The server checks the details, starts a session and hands the browser a cookie. The browser sends the cookie along with every further request, and the server knows who made it.

According to the project assignment, authentication earns two bonus points. It pays off as soon as certain data belongs only to signed-in people, for example their own entries or an area for a team. The sign-in belongs in the backend. An SPA on its own cannot sign anyone in securely, because a person can read and change everything that runs in their browser. Never store passwords in plain text, only as a hash made with a method such as Argon2id, as OWASP recommends. For a project, an identity provider is often the safer way.

The course API starts the session with POST /session and sets a cookie. HttpOnly keeps the cookie away from JavaScript, including code that someone smuggles into the page through a vulnerability. SameSite=Strict stops the browser from sending the cookie with requests that come from other websites.

app.js
const COOKIE = 'session'
// HttpOnly keeps the cookie away from JavaScript, SameSite=Strict away from other sites
const COOKIE_ATTRIBUTES = 'HttpOnly; SameSite=Strict; Path=/'
app.js
[
'POST',
/^\/session$/,
async ({ request, response, lang, text }) => {
const { number } = await readJson(request, text)
const member = state.member(lang, String(number ?? '').trim().toUpperCase())
if (member === undefined) throw new HttpError(401, { error: text.unknownMember })
const id = randomUUID()
sessions.set(id, member.number)
// Without Expires or Max-Age the cookie ends when the browser closes
response.setHeader('Set-Cookie', `${COOKIE}=${id}; ${COOKIE_ATTRIBUTES}`)
return [200, member]
},
],
['GET', /^\/session$/, ({ request, lang, text }) => [200, currentMember(request, lang, text)]],
[
'DELETE',
/^\/session$/,
({ request, response }) => {
sessions.delete(sessionId(request))
response.setHeader('Set-Cookie', `${COOKIE}=; ${COOKIE_ATTRIBUTES}; Max-Age=0`)
return [204]
},
],

The SPA does not know the session, it only keeps track of who is signed in. After a reload, the store of the case app asks the API once with GET /session who the cookie belongs to.

authStore.js
export const useAuthStore = defineStore('auth', () => {
const api = useApi()
const member = ref(null)
const isTeam = computed(() => member.value?.team === true)
// The question to the API after a reload, asked once
let restoring = null
// The session of the last login outlives a reload, the store does not.
// So the app asks the API once who is signed in. Every caller gets the same answer.
function restore() {
restoring ??= api.restoreSession().then(({ ok, data }) => {
if (ok) member.value = data
})
return restoring
}
// The API checks the member number and starts the session.
// Returns the message of the API for a failed login and an empty text when it worked.
async function signIn(number) {
const { ok, data } = await api.signIn(number)
if (!ok) return data.error
member.value = data
return ''
}
async function signOut() {
await api.signOut()
member.value = null
}
return { member, isTeam, restore, signIn, signOut }
})

The router guard sends people without a team account to the sign-in and back afterwards. It only makes sure that the interface fits.

index.js
// The team area needs a team account. Everybody else signs in first and comes back afterwards.
router.beforeEach(async (to) => {
if (!to.meta.requiresTeam) return
// The store is called inside the guard, when Pinia is installed. After a reload the guard
// waits until the API has said who is signed in.
const auth = useAuthStore()
await auth.restore()
if (!auth.isTeam) return { name: 'login', query: { redirect: to.fullPath } }
})

Protecting the data is the server's job. The Authorization Cheat Sheet of OWASP asks to check every request on the server and never to rely on checks in the browser. That is why the course API looks up the session of the cookie on every request. Without a session it answers 401, and only the team may record a return, otherwise the answer is 403.

app.js
function currentMember(request, lang, text) {
const number = sessions.get(sessionId(request))
if (number === undefined) throw new HttpError(401, { error: text.notSignedIn })
return state.member(lang, number)
}
app.js
[
'POST',
/^\/items\/(\d+)\/return$/,
({ request, lang, text, params }) => {
if (!currentMember(request, lang, text).team) throw new HttpError(403, { error: text.teamOnly })
const item = existingItem(lang, params[0], text)
if (item.availableFrom === null) throw new HttpError(409, { error: text.notOnLoan })
state.markReturned(item.id)
return [200, state.item(lang, item.id)]
},

Real sign-ins often go through an identity provider with OAuth 2.0 and OpenID Connect, a layer on top of OAuth that records who signed in. For SPAs, RFC 10017 of August 2026 describes three patterns, ordered by how secure they are. The most secure one is a backend for frontend, a server of your own that holds the tokens, gives the SPA only a session in a cookie and passes on the calls to the API. If the SPA fetches the tokens itself, it must use PKCE as defined in RFC 7636. According to OWASP, a token or a session ID never belongs in localStorage or sessionStorage, because every script of the page can read it there.

If the SPA and the API live on different sites, for example the SPA on github.io and the API with another provider, the browser does not send a cookie with SameSite=Strict or Lax. The cookie then needs SameSite=None and Secure, and the API needs protection against cross-site request forgery as OWASP describes it. It is simpler to run the SPA and the API under the same domain.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.