Skip to main content
All case studies

Full Stack · Security · Identity

Secure Authentication System

A full-stack JWT authentication system with short-lived access tokens, refresh tokens in HttpOnly cookies, and role-based route protection on both sides of the trust boundary.

Academic · Reference implementation
Role
Software Engineer — full stack
Period
2025

Stages covered

  • Architecture
  • Build

At a glance

2025

Backend

  • Node.js
  • Express
  • TypeScript
  • JSON Web Tokens

Frontend

  • React
  • Vite
  • TypeScript

Data & runtime

  • MySQL
  • Docker

Overview

A containerised authentication system built to get the details right rather than to be quick: a short-lived access token held in memory, a refresh token that JavaScript cannot read, silent renewal, and administrator and client roles enforced on the server as well as in the interface.

Problem

Most authentication implementations fail in the same place: token storage. A JWT in localStorage is readable by any script that gets onto the page, which turns a single cross-site scripting bug into full account takeover. A long-lived token makes that worse by removing any natural expiry on the damage.

The second failure is subtler. Hiding an admin button in the interface looks like authorisation and is not — it is presentation. The check that matters is the one on the server, because that is the one an attacker cannot skip.

Constraints

  • Assume cross-site scripting is possible and design so that it is not immediately fatal
  • Session continuity must not depend on the user re-entering credentials on every token expiry
  • Roles must be enforced at the trust boundary, not merely reflected in the UI
  • The whole stack must run reproducibly from a single container definition

Role and contribution

Software Engineer — full stack

  • Designed the two-token authentication flow and its storage model
  • Implemented issuing, verification and rotation on the Express side
  • Built protected client routes with transparent token renewal
  • Implemented role-based authorisation on both the API and the interface
  • Containerised the stack for reproducible local runs

System design

Architecture

Token flow and trust boundary

The React client posts credentials to the Express API. The API returns a short-lived access token in the response body, which the client keeps in memory, and a refresh token set as an HttpOnly cookie that JavaScript cannot read. Protected requests carry the access token in an Authorization header. When it expires, the client calls the refresh endpoint; the browser attaches the cookie automatically and a new access token is issued without user interaction. Every protected route verifies the token and then checks the caller's role against the route's requirement before the handler runs. User records live in MySQL.

Backend
  • Node.js
  • Express
  • TypeScript
  • JSON Web Tokens
Frontend
  • React
  • Vite
  • TypeScript
Data & runtime
  • MySQL
  • Docker

Authentication flow

Sign-in returns two credentials with deliberately different properties. The access token is short-lived and arrives in the response body, so the client holds it in memory and attaches it to protected requests. The refresh token is long-lived and arrives as an HttpOnly cookie, so it survives a page reload but is unreachable from JavaScript.

When a protected request fails because the access token has expired, the client calls the refresh endpoint. The browser attaches the refresh cookie automatically, the server issues a fresh access token, and the original request is retried. The user sees nothing. If the refresh token is also invalid, the session ends and the user is returned to sign-in.

  • Sign in → short-lived access token in the response body, refresh token as an HttpOnly cookie
  • Access token kept in memory and sent as a bearer credential
  • On expiry → silent refresh, then retry the original request
  • Both invalid → session ends, redirect to sign-in

Where the trust boundary sits

Protected client routes exist for user experience: they stop someone from landing on a page whose data will not load. They are not security. Every protected API route independently verifies the token and checks the role attached to it before the handler runs.

Stated plainly: the frontend decides what to show, the backend decides what is allowed. Anything enforced only in the first place is not enforced.

Error handling

Authentication failures return deliberately uninformative messages. A sign-in attempt that fails does not reveal whether the account exists, because the difference between 'no such user' and 'wrong password' is exactly what makes account enumeration cheap.

Internally the distinction between an expired token and an invalid one is preserved, because the client needs to know whether to attempt a refresh or give up — but that signal is a status code, not an explanation.

Key decisions and trade-offs

Decision

Keep the access token in memory and the refresh token in an HttpOnly cookie

Context
The tokens have to survive normal use without being readable by injected script.
Alternatives considered
  • Both tokens in localStorage — simplest, and readable by any script on the page
  • Both tokens in HttpOnly cookies — safe from script, but exposed to cross-site request forgery
  • Server-side sessions
Chosen approach
Short-lived access token in memory, long-lived refresh token in an HttpOnly cookie.
Reason
Cross-site scripting can only reach the access token, which expires quickly. The credential that would grant lasting access is unreachable from JavaScript entirely.
Trade-off
A page reload loses the in-memory access token, so the app must refresh on startup, and the cookie-based refresh endpoint needs cross-site request forgery protection of its own.

Decision

Enforce roles on the server as well as in the interface

Context
Hiding administrator controls in the UI already prevents most accidental access.
Alternative considered
  • Rely on conditional rendering of admin-only routes and controls
Chosen approach
Role checks in API middleware, with the client-side checks kept purely for user experience.
Reason
Client-side checks are advisory — anyone can call the endpoint directly. The server check is the only one an attacker cannot skip.
Trade-off
The role rule is expressed in two places and the two must be kept in agreement.

Result

  • A working two-token flow with transparent renewal and no credential reachable from page scripts
  • Role-based authorisation enforced at the trust boundary rather than in presentation
  • A reproducible containerised stack, typed end to end

Lessons learned

  • Token storage is the decision that determines how bad a cross-site scripting bug turns out to be.
  • Route guards in the client are user experience. Authorisation is the middleware.
  • Silent refresh is where subtle bugs live — concurrent requests hitting an expired token at the same moment need a single shared refresh, not one each.
  • Vague authentication errors are a feature, and worth the small cost in developer convenience.

Next improvements

  • Refresh-token rotation with reuse detection, so a stolen refresh token invalidates the session
  • Explicit cross-site request forgery protection on the cookie-authenticated refresh endpoint
  • Server-side revocation, which stateless JWTs do not provide on their own
  • Rate limiting and lockout on the sign-in path