ASVS Level 1 Session Management in Node.js and Go

Series OWASP ASVS 5.0 Level 1 Part 9/13 All parts

A session is what lets a user log in once and stay logged in. The browser gets a token, sends it back with every request, and your application decides who the request belongs to. Chapter V7 Session Management of the OWASP Application Security Verification Standard has six Level 1 requirements, and all six are about that token: who is allowed to decide what it means, how it is generated, when it changes, and when it stops working.

This part covers all six with runnable code in Node.js with Fastify and in Go with Fiber. Pick your stack once and the whole article follows it. Every control is a broken version next to a fixed version, one small change apart, so you can run both and see the difference.

Part 4 already covered the cookie itself: the __Host- prefix, Secure, HttpOnly, and SameSite. Those settings protect the token in transit and in the browser. This part is about what you put inside the cookie and what your server does with it.

Conceptual Overview

A session token is a claim, not a proof. Anything the browser sends can be edited by whoever controls the browser, and curl can send any cookie at all without a browser being involved. The token itself proves nothing. What makes it meaningful is that the server can look it up and find out which login it belongs to.

There are two kinds of session token. A reference token is a meaningless random string. It carries no information at all, and the server keeps a table mapping tokens to logins. A self-contained token carries the information inside it, usually as a JSON Web Token (JWT), and is signed so the server can detect tampering. Both are allowed by ASVS. This article uses reference tokens, because they are simpler to get right and because ending a session is a single delete. The last step explains what changes if you choose JWTs instead.

Entropy is the number of guesses an attacker needs. A token with 128 bits of entropy has 2 to the power of 128 possible values, which nothing can search. A token built from the current time has perhaps a thousand realistic values, because the attacker knows roughly when you logged in. The requirement asks for at least 128 bits, and the only way to get there is a cryptographically secure pseudo-random number generator (CSPRNG), which is a random source designed so that seeing past output tells you nothing about future output.

Session fixation is an attack on the moment of login. If the token stays the same when an anonymous visitor becomes a logged in user, then anybody who can plant a token in the victim’s browser before they log in owns the account afterwards. The attacker never needs the password. The fix is to throw the old token away and issue a new one the moment authentication succeeds.

Logging out is a server action, not a browser action. Deleting the cookie only tells one browser to forget one copy of the token. If the token was ever written to a log file, captured by a proxy, copied by malware, or left open in another tab, it still works. Termination has to happen in the session table.

Prerequisites

Step 1: Set Up a Scratch Project

Each control is a pair of servers named -bad and -good, both listening on port 3000, run one at a time.

mkdir -p ~/asvs-v7/node
cd ~/asvs-v7/node
npm init -y
npm pkg set type=module
npm install fastify @fastify/cookie argon2

Run a server with node trust-bad.mjs, and stop it with Ctrl+C before starting the next one.

mkdir -p ~/asvs-v7/go
cd ~/asvs-v7/go
go mod init asvs-v7
go get github.com/gofiber/fiber/[email protected]
go get github.com/alexedwards/argon2id

Each server is its own cmd/ directory. Build them into named binaries so you always know which one is running:

go build -o bin/trust-bad ./cmd/trust-bad
./bin/trust-bad

Stop it with Ctrl+C before starting the next one. Building to a named file matters: pkill -f "go run" does not reliably stop the child process, and a leftover server holding port 3000 will answer requests you think are going to the new one.

The session store in these examples is a map in process memory, with no lock around it. That is fine for a scratch server with one user. A real application keeps sessions in Redis or in a database table. Otherwise a restart logs everybody out, and with several instances running, a user who logs out on one stays logged in on the others.

Step 2: Let Only the Server Decide Who the Token Belongs To

V7.2.1 Verify that the application performs all session token verification using a trusted, backend service.

V7.2.2 Verify that the application uses either self-contained or reference tokens that are dynamically generated for session management, i.e. not using static API secrets and keys.

The broken version puts the answer straight into the cookie. After a correct password it sets [email protected], and every later request reads that cookie and believes it. There is no verification anywhere, because there is nothing to verify against. The value is also the same on every login forever, which is the static key that V7.2.2 rules out.

Create trust-bad.mjs:

import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import argon2 from 'argon2'

const accounts = new Map([
  ['[email protected]', { hash: await argon2.hash('correct horse battery staple'), balance: 120 }],
  ['[email protected]', { hash: await argon2.hash('a totally different long password'), balance: 98000 }],
])

const app = Fastify()
await app.register(cookie)

app.post('/login', async (req, reply) => {
  const { email, password } = req.body
  const account = accounts.get(email)
  if (!account || !(await argon2.verify(account.hash, password))) {
    return reply.code(401).send({ error: 'invalid credentials' })
  }

  reply.setCookie('uid', email, { path: '/', httpOnly: true, sameSite: 'lax' })
  return { loggedIn: email }
})

app.get('/me', async (req, reply) => {
  const account = accounts.get(req.cookies.uid)
  if (!account) return reply.code(401).send({ error: 'not logged in' })
  return { email: req.cookies.uid, balance: account.balance }
})

await app.listen({ port: 3000 })

trust-good.mjs keeps the mapping on the server. The cookie holds a random token that means nothing on its own, and /me answers only if that exact token is in the sessions map:

import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import argon2 from 'argon2'
import { randomBytes } from 'node:crypto'

const accounts = new Map([
  ['[email protected]', { hash: await argon2.hash('correct horse battery staple'), balance: 120 }],
  ['[email protected]', { hash: await argon2.hash('a totally different long password'), balance: 98000 }],
])
const sessions = new Map()

const app = Fastify()
await app.register(cookie)

app.post('/login', async (req, reply) => {
  const { email, password } = req.body
  const account = accounts.get(email)
  if (!account || !(await argon2.verify(account.hash, password))) {
    return reply.code(401).send({ error: 'invalid credentials' })
  }

  const token = randomBytes(32).toString('hex')
  sessions.set(token, email)
  reply.setCookie('sid', token, { path: '/', httpOnly: true, sameSite: 'lax' })
  return { loggedIn: email }
})

app.get('/me', async (req, reply) => {
  const email = sessions.get(req.cookies.sid)
  if (!email) return reply.code(401).send({ error: 'not logged in' })
  return { email, balance: accounts.get(email).balance }
})

await app.listen({ port: 3000 })

Create cmd/trust-bad/main.go:

package main

import (
	"log"

	"github.com/alexedwards/argon2id"
	"github.com/gofiber/fiber/v3"
)

type account struct {
	hash    string
	balance int
}

type credentials struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

var accounts = map[string]account{}

func main() {
	alice, _ := argon2id.CreateHash("correct horse battery staple", argon2id.DefaultParams)
	bob, _ := argon2id.CreateHash("a totally different long password", argon2id.DefaultParams)
	accounts["[email protected]"] = account{hash: alice, balance: 120}
	accounts["[email protected]"] = account{hash: bob, balance: 98000}

	app := fiber.New()

	app.Post("/login", func(c fiber.Ctx) error {
		var in credentials
		if err := c.Bind().Body(&in); err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
		}

		acc, ok := accounts[in.Email]
		match, err := argon2id.ComparePasswordAndHash(in.Password, acc.hash)
		if !ok || err != nil || !match {
			return c.Status(401).JSON(fiber.Map{"error": "invalid credentials"})
		}

		c.Cookie(&fiber.Cookie{Name: "uid", Value: in.Email, Path: "/", HTTPOnly: true, SameSite: "Lax"})
		return c.JSON(fiber.Map{"loggedIn": in.Email})
	})

	app.Get("/me", func(c fiber.Ctx) error {
		email := c.Cookies("uid")
		acc, ok := accounts[email]
		if !ok {
			return c.Status(401).JSON(fiber.Map{"error": "not logged in"})
		}
		return c.JSON(fiber.Map{"email": email, "balance": acc.balance})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/trust-good/main.go keeps the mapping on the server. The cookie holds a random token that means nothing on its own, and /me answers only if that exact token is in the sessions map:

package main

import (
	"crypto/rand"
	"log"

	"github.com/alexedwards/argon2id"
	"github.com/gofiber/fiber/v3"
)

type account struct {
	hash    string
	balance int
}

type credentials struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

var accounts = map[string]account{}
var sessions = map[string]string{}

func main() {
	alice, _ := argon2id.CreateHash("correct horse battery staple", argon2id.DefaultParams)
	bob, _ := argon2id.CreateHash("a totally different long password", argon2id.DefaultParams)
	accounts["[email protected]"] = account{hash: alice, balance: 120}
	accounts["[email protected]"] = account{hash: bob, balance: 98000}

	app := fiber.New()

	app.Post("/login", func(c fiber.Ctx) error {
		var in credentials
		if err := c.Bind().Body(&in); err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
		}

		acc, ok := accounts[in.Email]
		match, err := argon2id.ComparePasswordAndHash(in.Password, acc.hash)
		if !ok || err != nil || !match {
			return c.Status(401).JSON(fiber.Map{"error": "invalid credentials"})
		}

		token := rand.Text()
		sessions[token] = in.Email
		c.Cookie(&fiber.Cookie{Name: "sid", Value: token, Path: "/", HTTPOnly: true, SameSite: "Lax"})
		return c.JSON(fiber.Map{"loggedIn": in.Email})
	})

	app.Get("/me", func(c fiber.Ctx) error {
		email, ok := sessions[c.Cookies("sid")]
		if !ok {
			return c.Status(401).JSON(fiber.Map{"error": "not logged in"})
		}
		return c.JSON(fiber.Map{"email": email, "balance": accounts[email].balance})
	})

	log.Fatal(app.Listen(":3000"))
}

rand.Text() comes from crypto/rand in Go 1.24 and later. It returns 26 base32 characters carrying at least 128 bits of randomness. Step 3 explains why that number matters.

Verify against the broken server. Log in as Alice, then ask for the account without ever knowing Bob’s password:

$ curl -s -i -X POST -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/login | grep -i set-cookie
set-cookie: [email protected]; Path=/; HttpOnly; SameSite=Lax

$ curl -s -b '[email protected]' http://localhost:3000/me
{"email":"[email protected]","balance":120}

$ curl -s -b '[email protected]' http://localhost:3000/me
{"email":"[email protected]","balance":98000}

The last command is the whole failure. Changing one word in a cookie moved 98000 from someone else’s account into the response. Now run the fixed server and try the same trick:

$ curl -s -i -X POST -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/login | grep -i set-cookie
set-cookie: sid=dff6b4471b6c0414c11967704de79ae9f08f9a805cf172814a2c8b8fb0e28e74; Path=/; HttpOnly; SameSite=Lax

$ curl -s -b '[email protected]' http://localhost:3000/me
{"error":"not logged in"}

The cookie no longer carries an answer, so editing it cannot produce one. The same rule covers the modern version of this bug. A single-page application that decodes a JWT in the browser and shows the admin menu is doing presentation, not verification. The server checks the signature and the expiry on every request that matters.

Step 3: Generate Tokens With a CSPRNG

V7.2.3 Verify that if reference tokens are used to represent user sessions, they are unique and generated using a cryptographically secure pseudo-random number generator (CSPRNG) and possess at least 128 bits of entropy.

The token in Step 2 was already random, so this step isolates the one line that makes it random and shows what happens when it is not. A very common first attempt is a timestamp, because timestamps are unique and easy. Unique is not the same as unguessable.

Both files below start from trust-good in Step 2. Move the token generation into a function called newToken, call it from /login, and the two versions then differ by that function alone.

Copy trust-good.mjs to token-bad.mjs. Remove the randomBytes import, add the generator above the routes, and call it in /login:

const newToken = () => 'sess_' + Date.now()

// inside app.post('/login', ...), replacing the randomBytes line:
  const token = newToken()

Copy that to token-good.mjs and change the generator back to a real random source:

import { randomBytes } from 'node:crypto'

const newToken = () => randomBytes(32).toString('hex')

randomBytes is Node’s CSPRNG. Thirty-two bytes is 256 bits, comfortably above the 128 the requirement asks for, and toString('hex') turns it into 64 characters that are safe to put in a cookie.

Copy cmd/trust-good/main.go to cmd/token-bad/main.go. Swap crypto/rand out of the imports, add the generator, and call it in /login:

import (
	"log"
	"strconv"
	"time"

	"github.com/alexedwards/argon2id"
	"github.com/gofiber/fiber/v3"
)

func newToken() string {
	return "sess_" + strconv.FormatInt(time.Now().UnixMilli(), 10)
}

// inside app.Post("/login", ...), replacing the rand.Text() line:
		token := newToken()

Copy that to cmd/token-good/main.go and change the generator back to a real random source:

	"crypto/rand"

func newToken() string {
	return rand.Text()
}

Drop strconv and time from the imports, or the build fails on unused imports. Note the package: math/rand is fast and repeatable, which is exactly what you do not want. crypto/rand reads from the operating system’s random source.

Now attack the broken server. Assume the attacker knows which second Alice logged in, which is not a stretch: a “new sign in to your account” email carries a timestamp, and so does any activity feed. That leaves one thousand possible tokens, one per millisecond.

Start the broken server, log in, and then run the search:

$ sec=$(date +%s)
$ curl -s -i -X POST -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/login | grep -i set-cookie
set-cookie: sid=sess_1787618840344; Path=/; HttpOnly; SameSite=Lax

$ for ms in $(seq $((sec*1000)) $((sec*1000+999))); do
    code=$(curl -s -o /dev/null -w '%{http_code}' -b "sid=sess_$ms" http://localhost:3000/me)
    if [ "$code" = 200 ]; then echo "hijacked after $((ms-sec*1000+1)) guesses: sess_$ms"; break; fi
  done
hijacked after 345 guesses: sess_1787618840344

Three hundred and forty five requests, about two seconds, no password involved. Run the same loop against the fixed server and it prints nothing, because the thousand candidates it tries are a thousand values out of 2 to the power of 128. That gap is what “128 bits of entropy” means in practice.

One warning: a long token is not automatically a strong token. Math.random() produces a long-looking string from an internal state far smaller than 128 bits. Judge the generator, not the length of the string it prints.

Step 4: Issue a New Token When the User Logs In

V7.2.4 Verify that the application generates a new session token on user authentication, including re-authentication, and terminates the current session token.

Most applications give visitors a session before they log in, so an anonymous shopping cart or a partly filled form survives a page reload. The tempting thing to do at login is to keep that session and just attach the user to it. That is session fixation, and it hands the account to anyone who knew the token beforehand.

The attacker’s move is to make the victim’s browser use a token the attacker already has: a link carrying a session id, a cookie written by a compromised subdomain, or a cross-site scripting bug on any page of the site. In each case the attacker knows the token before the victim types their password.

fixation-bad.mjs gives every visitor a cart session, and reuses it at login:

import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import argon2 from 'argon2'
import { randomBytes } from 'node:crypto'

const accounts = new Map([['[email protected]', await argon2.hash('correct horse battery staple')]])
const sessions = new Map()

const newToken = () => randomBytes(32).toString('hex')

const app = Fastify()
await app.register(cookie)

app.get('/cart', async (req, reply) => {
  let token = req.cookies.sid
  if (!sessions.has(token)) {
    token = newToken()
    sessions.set(token, { email: null, items: [] })
    reply.setCookie('sid', token, { path: '/', httpOnly: true, sameSite: 'lax' })
  }
  return { items: sessions.get(token).items }
})

app.post('/login', async (req, reply) => {
  const { email, password } = req.body
  const stored = accounts.get(email)
  if (!stored || !(await argon2.verify(stored, password))) {
    return reply.code(401).send({ error: 'invalid credentials' })
  }

  const token = req.cookies.sid ?? newToken()
  sessions.set(token, { email, items: sessions.get(token)?.items ?? [] })
  reply.setCookie('sid', token, { path: '/', httpOnly: true, sameSite: 'lax' })
  return { loggedIn: email }
})

app.get('/me', async (req, reply) => {
  const session = sessions.get(req.cookies.sid)
  if (!session?.email) return reply.code(401).send({ error: 'not logged in' })
  return { email: session.email }
})

await app.listen({ port: 3000 })

fixation-good.mjs changes only the four lines in /login that decide the token. The cart survives, the token does not:

  const items = sessions.get(req.cookies.sid)?.items ?? []
  sessions.delete(req.cookies.sid)
  const token = newToken()
  sessions.set(token, { email, items })

cmd/fixation-bad/main.go gives every visitor a cart session, and reuses it at login:

package main

import (
	"crypto/rand"
	"log"

	"github.com/alexedwards/argon2id"
	"github.com/gofiber/fiber/v3"
)

type session struct {
	email string
	items []string
}

type credentials struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

var accounts = map[string]string{}
var sessions = map[string]session{}

func setCookie(c fiber.Ctx, token string) {
	c.Cookie(&fiber.Cookie{Name: "sid", Value: token, Path: "/", HTTPOnly: true, SameSite: "Lax"})
}

func main() {
	accounts["[email protected]"], _ = argon2id.CreateHash("correct horse battery staple", argon2id.DefaultParams)

	app := fiber.New()

	app.Get("/cart", func(c fiber.Ctx) error {
		token := c.Cookies("sid")
		if _, ok := sessions[token]; !ok {
			token = rand.Text()
			sessions[token] = session{items: []string{}}
			setCookie(c, token)
		}
		return c.JSON(fiber.Map{"items": sessions[token].items})
	})

	app.Post("/login", func(c fiber.Ctx) error {
		var in credentials
		if err := c.Bind().Body(&in); err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
		}

		match, err := argon2id.ComparePasswordAndHash(in.Password, accounts[in.Email])
		if err != nil || !match {
			return c.Status(401).JSON(fiber.Map{"error": "invalid credentials"})
		}

		token := c.Cookies("sid")
		if token == "" {
			token = rand.Text()
		}
		sessions[token] = session{email: in.Email, items: sessions[token].items}
		setCookie(c, token)
		return c.JSON(fiber.Map{"loggedIn": in.Email})
	})

	app.Get("/me", func(c fiber.Ctx) error {
		s, ok := sessions[c.Cookies("sid")]
		if !ok || s.email == "" {
			return c.Status(401).JSON(fiber.Map{"error": "not logged in"})
		}
		return c.JSON(fiber.Map{"email": s.email})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/fixation-good/main.go changes only the lines in /login that decide the token. The cart survives, the token does not:

		items := sessions[c.Cookies("sid")].items
		delete(sessions, c.Cookies("sid"))
		token := rand.Text()
		sessions[token] = session{email: in.Email, items: items}

Verify by playing both roles. The attacker gets a token from /cart, the victim’s browser is made to use it, the victim logs in with their real password, and then the attacker replays the token they had all along:

$ T=$(curl -s -i http://localhost:3000/cart | grep -io 'sid=[a-f0-9]*' | cut -d= -f2)
$ echo $T
3eed0fab460f152f1bc933551064c78230bed7a6393fd594c848cedf3f0caa07

$ curl -s -i -b "sid=$T" -X POST -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/login | grep -iE 'set-cookie|loggedIn'
set-cookie: sid=3eed0fab460f152f1bc933551064c78230bed7a6393fd594c848cedf3f0caa07; Path=/; HttpOnly; SameSite=Lax
{"loggedIn":"[email protected]"}

$ curl -s -b "sid=$T" http://localhost:3000/me
{"email":"[email protected]"}

The set-cookie line is the tell: the server handed back the same token the attacker planted. Against the fixed server the same three commands end differently:

$ curl -s -i -b "sid=$T" -X POST -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/login | grep -i set-cookie
set-cookie: sid=a72b37be7fd762a8d9372f9327d166ea2d7405eeb5b088121c1544475c79d8ad; Path=/; HttpOnly; SameSite=Lax

$ curl -s -b "sid=$T" http://localhost:3000/me
{"error":"not logged in"}

The requirement says “including re-authentication”, so the same rule applies anywhere the user proves who they are again: a step up prompt before changing a password, a second factor, or an administrator switching into a support account. Every one of those moments ends the old token and starts a new one.

Step 5: End the Session on the Server

V7.4.1 Verify that when session termination is triggered (such as logout or expiration), the application disallows any further use of the session. For reference tokens or stateful sessions, this means invalidating the session data at the application backend. Applications using self-contained tokens will need a solution such as maintaining a list of terminated tokens, disallowing tokens produced before a per-user date and time or rotating a per-user signing key.

V7.4.2 Verify that the application terminates all active sessions when a user account is disabled or deleted (such as an employee leaving the company).

Three events end a session: the user logs out, time runs out, or somebody disables the account. The broken server handles none of them. Logout clears the cookie, the expiry is a Max-Age on the cookie, and disabling the account sets a flag that nothing reads. In every case the token still opens the account.

end-bad.mjs:

import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import argon2 from 'argon2'
import { randomBytes } from 'node:crypto'

const accounts = new Map([
  ['[email protected]', { hash: await argon2.hash('correct horse battery staple'), disabled: false }],
])
const sessions = new Map()

const app = Fastify()
await app.register(cookie)

app.post('/login', async (req, reply) => {
  const { email, password } = req.body
  const account = accounts.get(email)
  if (!account || !(await argon2.verify(account.hash, password))) {
    return reply.code(401).send({ error: 'invalid credentials' })
  }

  const token = randomBytes(32).toString('hex')
  sessions.set(token, { email })
  reply.setCookie('sid', token, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 900 })
  return { loggedIn: email }
})

app.get('/me', async (req, reply) => {
  const session = sessions.get(req.cookies.sid)
  if (!session) return reply.code(401).send({ error: 'not logged in' })
  return { email: session.email }
})

app.post('/logout', async (req, reply) => {
  reply.clearCookie('sid', { path: '/' })
  return { loggedOut: true }
})

app.post('/admin/disable', async (req) => {
  accounts.get(req.body.email).disabled = true
  return { disabled: req.body.email }
})

await app.listen({ port: 3000 })

end-good.mjs adds a server side expiry, deletes the session on logout, and clears every session belonging to a disabled account:

import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import argon2 from 'argon2'
import { randomBytes } from 'node:crypto'

const accounts = new Map([
  ['[email protected]', { hash: await argon2.hash('correct horse battery staple'), disabled: false }],
])
const sessions = new Map()
const TTL_SECONDS = Number(process.env.SESSION_TTL_SECONDS ?? 15 * 60)

function readSession(token) {
  const session = sessions.get(token)
  if (!session) return null
  if (session.expiresAt <= Date.now()) {
    sessions.delete(token)
    return null
  }
  return session
}

function endAllSessionsFor(email) {
  for (const [token, session] of sessions) {
    if (session.email === email) sessions.delete(token)
  }
}

const app = Fastify()
await app.register(cookie)

app.post('/login', async (req, reply) => {
  const { email, password } = req.body
  const account = accounts.get(email)
  if (!account || account.disabled || !(await argon2.verify(account.hash, password))) {
    return reply.code(401).send({ error: 'invalid credentials' })
  }

  const token = randomBytes(32).toString('hex')
  sessions.set(token, { email, expiresAt: Date.now() + TTL_SECONDS * 1000 })
  reply.setCookie('sid', token, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: TTL_SECONDS })
  return { loggedIn: email }
})

app.get('/me', async (req, reply) => {
  const session = readSession(req.cookies.sid)
  if (!session) return reply.code(401).send({ error: 'not logged in' })
  return { email: session.email }
})

app.post('/logout', async (req, reply) => {
  sessions.delete(req.cookies.sid)
  reply.clearCookie('sid', { path: '/' })
  return { loggedOut: true }
})

app.post('/admin/disable', async (req) => {
  accounts.get(req.body.email).disabled = true
  endAllSessionsFor(req.body.email)
  return { disabled: req.body.email }
})

await app.listen({ port: 3000 })

cmd/end-bad/main.go:

package main

import (
	"crypto/rand"
	"log"

	"github.com/alexedwards/argon2id"
	"github.com/gofiber/fiber/v3"
)

type account struct {
	hash     string
	disabled bool
}

type credentials struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

var accounts = map[string]account{}
var sessions = map[string]string{}

func main() {
	hash, _ := argon2id.CreateHash("correct horse battery staple", argon2id.DefaultParams)
	accounts["[email protected]"] = account{hash: hash}

	app := fiber.New()

	app.Post("/login", func(c fiber.Ctx) error {
		var in credentials
		if err := c.Bind().Body(&in); err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
		}

		match, err := argon2id.ComparePasswordAndHash(in.Password, accounts[in.Email].hash)
		if err != nil || !match {
			return c.Status(401).JSON(fiber.Map{"error": "invalid credentials"})
		}

		token := rand.Text()
		sessions[token] = in.Email
		c.Cookie(&fiber.Cookie{Name: "sid", Value: token, Path: "/", HTTPOnly: true, SameSite: "Lax", MaxAge: 900})
		return c.JSON(fiber.Map{"loggedIn": in.Email})
	})

	app.Get("/me", func(c fiber.Ctx) error {
		email, ok := sessions[c.Cookies("sid")]
		if !ok {
			return c.Status(401).JSON(fiber.Map{"error": "not logged in"})
		}
		return c.JSON(fiber.Map{"email": email})
	})

	app.Post("/logout", func(c fiber.Ctx) error {
		c.ClearCookie("sid")
		return c.JSON(fiber.Map{"loggedOut": true})
	})

	app.Post("/admin/disable", func(c fiber.Ctx) error {
		var in credentials
		if err := c.Bind().Body(&in); err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
		}
		acc := accounts[in.Email]
		acc.disabled = true
		accounts[in.Email] = acc
		return c.JSON(fiber.Map{"disabled": in.Email})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/end-good/main.go adds a server side expiry, deletes the session on logout, and clears every session belonging to a disabled account:

package main

import (
	"crypto/rand"
	"log"
	"os"
	"strconv"
	"time"

	"github.com/alexedwards/argon2id"
	"github.com/gofiber/fiber/v3"
)

type account struct {
	hash     string
	disabled bool
}

type session struct {
	email     string
	expiresAt time.Time
}

type credentials struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

var accounts = map[string]account{}
var sessions = map[string]session{}
var ttl = sessionTTL()

func sessionTTL() time.Duration {
	if v, err := strconv.Atoi(os.Getenv("SESSION_TTL_SECONDS")); err == nil && v > 0 {
		return time.Duration(v) * time.Second
	}
	return 15 * time.Minute
}

func readSession(token string) (session, bool) {
	s, ok := sessions[token]
	if !ok {
		return session{}, false
	}
	if !s.expiresAt.After(time.Now()) {
		delete(sessions, token)
		return session{}, false
	}
	return s, true
}

func endAllSessionsFor(email string) {
	for token, s := range sessions {
		if s.email == email {
			delete(sessions, token)
		}
	}
}

func main() {
	hash, _ := argon2id.CreateHash("correct horse battery staple", argon2id.DefaultParams)
	accounts["[email protected]"] = account{hash: hash}

	app := fiber.New()

	app.Post("/login", func(c fiber.Ctx) error {
		var in credentials
		if err := c.Bind().Body(&in); err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
		}

		acc := accounts[in.Email]
		match, err := argon2id.ComparePasswordAndHash(in.Password, acc.hash)
		if err != nil || !match || acc.disabled {
			return c.Status(401).JSON(fiber.Map{"error": "invalid credentials"})
		}

		token := rand.Text()
		sessions[token] = session{email: in.Email, expiresAt: time.Now().Add(ttl)}
		c.Cookie(&fiber.Cookie{Name: "sid", Value: token, Path: "/", HTTPOnly: true, SameSite: "Lax", MaxAge: int(ttl.Seconds())})
		return c.JSON(fiber.Map{"loggedIn": in.Email})
	})

	app.Get("/me", func(c fiber.Ctx) error {
		s, ok := readSession(c.Cookies("sid"))
		if !ok {
			return c.Status(401).JSON(fiber.Map{"error": "not logged in"})
		}
		return c.JSON(fiber.Map{"email": s.email})
	})

	app.Post("/logout", func(c fiber.Ctx) error {
		delete(sessions, c.Cookies("sid"))
		c.ClearCookie("sid")
		return c.JSON(fiber.Map{"loggedOut": true})
	})

	app.Post("/admin/disable", func(c fiber.Ctx) error {
		var in credentials
		if err := c.Bind().Body(&in); err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "invalid body"})
		}
		acc := accounts[in.Email]
		acc.disabled = true
		accounts[in.Email] = acc
		endAllSessionsFor(in.Email)
		return c.JSON(fiber.Map{"disabled": in.Email})
	})

	log.Fatal(app.Listen(":3000"))
}

Verify all three events. Log in twice into two cookie jars, which stands in for the same person on a laptop and a phone. SESSION_TTL_SECONDS overrides the fifteen minute default so you are not waiting on a clock; the Go build reads the same variable, so start that one with SESSION_TTL_SECONDS=60 ./bin/end-good.

$ SESSION_TTL_SECONDS=60 node end-good.mjs

$ curl -s -c laptop.txt -X POST -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/login
{"loggedIn":"[email protected]"}

$ curl -s -c phone.txt -X POST -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/login
{"loggedIn":"[email protected]"}

$ curl -s -X POST -b laptop.txt http://localhost:3000/logout
{"loggedOut":true}

$ curl -s -b laptop.txt http://localhost:3000/me
{"error":"not logged in"}

Sending -b laptop.txt after logging out is the point of the test. The cookie file still holds the token, exactly like an attacker who copied it, and the server refuses it anyway. Against end-bad the same command returns {"email":"[email protected]"}.

Now disable the account and replay the phone, which was never logged out and never touched:

$ curl -s -X POST -H 'content-type: application/json' \
    -d '{"email":"[email protected]"}' http://localhost:3000/admin/disable
{"disabled":"[email protected]"}

$ curl -s -b phone.txt http://localhost:3000/me
{"error":"not logged in"}

That leaves expiry. Restart the server with a five second lifetime, which also clears the disabled flag so you can log in again:

$ SESSION_TTL_SECONDS=5 node end-good.mjs

$ curl -s -c tab.txt -X POST -H 'content-type: application/json' \
    -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
    http://localhost:3000/login >/dev/null
$ sleep 6
$ curl -s -b tab.txt http://localhost:3000/me
{"error":"not logged in"}

Against end-bad all three of those replays return {"email":"[email protected]"}, because nothing in that server ever removes a session.

The /admin/disable route in these examples has no authorization check, because they are scratch servers. In a real application, disabling an account is an administrative action and needs the checks from V8 Authorization.

If you use JWTs instead of reference tokens, this step is where it costs you. A signed token is valid until it expires, and the server holds nothing to delete. The requirement names the three ways out: keep a list of terminated tokens, refuse any token issued before a per-user timestamp, or rotate a per-user signing key. All three add a lookup on every request, which is the lookup people chose JWTs to avoid. Short lived access tokens plus a revocable refresh token is usually the practical answer. Just do not call it logout when what you have is a cookie being deleted.

Common Mistakes and Troubleshooting

Checking the session in the frontend. Hiding the admin button when the JWT payload says role: user is a display choice. Nothing stops a request going straight to the API. Every protected route checks on the server, every time.

Reusing one token for the API and the browser. A long lived static API key that also acts as a session is the failure V7.2.2 names. Sessions are created and destroyed per login; API keys are a different mechanism with different rules.

Setting only Max-Age on the cookie and calling it expiry. The browser will stop sending the cookie, but the token stays valid in your store. Store expiresAt next to the session and check it on every read, as readSession does above.

Deleting only the current session when an employee leaves. They may be logged in on a phone, a home laptop, and a tablet. V7.4.2 asks for all of them, which is why the fixed server iterates the store instead of deleting one key.

Rotating the token at login but not at re-authentication. The step up prompt before a sensitive action is authentication, so it gets a new token too.

Best Practices

Give sessions both an idle timeout and an absolute lifetime. Idle timeout ends a session that has gone quiet, and the absolute lifetime ends it no matter how active it is, so a stolen token cannot live forever.

Delete sessions in the same transaction as the account. Whatever removes the user row removes their sessions too, or a deleted user stays logged in.

Show users their active sessions and let them end one. It is a small feature that turns “I think somebody is in my account” into an action the user can take themselves.

Write the tests as replay tests. Capture a token, trigger the event, send the token again, and assert 401. That is exactly how a reviewer will check the requirement, and it catches the day someone changes logout to clear the cookie only.

Prefer reference tokens unless you have a reason not to. They cost one lookup per request and give you working revocation for free. Reach for self-contained tokens when you genuinely need stateless verification across services, and plan the revocation story before you do.

Conclusion

Chapter V7 Session Management is closed. The session decision happens on the server, the token comes from a CSPRNG, logging in throws away whatever token the browser arrived with, and logout, expiry, and disabling an account all remove the session from the store rather than asking the browser to forget it.

The test worth keeping from this part is the last one. Save a token, trigger the thing that should end the session, and send the token again. If it still works, the session never ended, whatever the user interface said.

Mark V7.2.1, V7.2.2, V7.2.3, V7.2.4, V7.4.1, and V7.4.2 as passed in your own record.

The next part covers V8 Authorization and its four Level 1 requirements: now that you know who is making the request, deciding what they are allowed to do with it.

The OWASP Application Security Verification Standard is licensed under Creative Commons Attribution-ShareAlike 4.0, which is what permits the requirement text to be reproduced here.

All tutorials →

Latest Tutorials

Support this site