JWT Authentication for Students: Tokens, Signatures, Refresh Flows and a Node.js Implementation

How does JWT login actually work? When a user logs in, the server issues a signed token in three parts — header, payload, signature. The client sends it back as an Authorization: Bearer header, and the server verifies the signature instead of looking up a session. This guide decodes a real token by hand, walks through the full login and refresh flow, and builds a working Node.js implementation with bcrypt password hashing, token rotation, and storage rules that survive a viva.

Written by Projectech16 min readPublished
For B.E./B.Tech Computer Science and IT students adding login and protected APIs to their final-year web projects Topics: JWT, Node.js, Express, bcrypt, JSON Web Tokens
Illustration of JWT authentication: a brass key handing a glowing sealed token to a server rack and a laptop login screen, linked by a chain motif.
Illustration generated for this guide.
In this guide

A login system that stores a session row in the database for every logged-in user works fine at classroom scale. The moment your project has a separate frontend and backend — a React app talking to a Node API, or a mobile app calling the same endpoints — passing identity around with server-side sessions starts to fight you. JSON Web Tokens (JWTs) are the standard answer: at login the server hands the client a signed, self-contained token, and the client presents that token with every request. This guide explains the token format by decoding a real token by hand, walks through the complete login and refresh flow, and builds a working Node.js implementation you can adapt for your own project.

Short answer: what a JWT is and why your project uses one

A JWT is a compact, URL-safe string in three parts — header, payload, signature — separated by dots. The header says how the token was signed, the payload carries claims (who the user is, their role, when the token expires), and the signature proves the payload was issued by your server and not tampered with. Your API verifies the signature on every request instead of looking up a session in the database, which makes the API stateless: any server instance can verify any token using only the secret key. That is the whole trick, and everything else in this guide — refresh tokens, storage rules, revocation — is engineering around the consequences of that trick.

Anatomy of a token: decode one by hand

Here is a real, working token (signed below with the throwaway secret dev-secret-do-not-ship-this, so you can verify every step yourself):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQ4MjEiLCJuYW1lIjoiQWFyYXYgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3ODc2MTYwMDAsImV4cCI6MTc4NzYxNjkwMH0.WAS6DgCOUxdAqOYytXIrrkojMUisVTJoCvUuDNZvytM

Split it on the dots. You get three base64url-encoded segments:

Segment Encoded value Decodes to
Header eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 {"alg":"HS256","typ":"JWT"}
Payload eyJzdWIiOiJ1c2VyXzQ4MjEiLCJuYW1lIjoiQWFyYXYgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3ODc2MTYwMDAsImV4cCI6MTc4NzYxNjkwMH0 {"sub":"user_4821","name":"Aarav Sharma","role":"student","iat":1787616000,"exp":1787616900}
Signature WAS6DgCOUxdAqOYytXIrrkojMUisVTJoCvUuDNZvytM HMAC-SHA256 of header.payload, keyed with the secret

Verify it yourself with Python — no libraries needed beyond the standard one:

import base64, hmac, hashlib

def b64u_decode(s):
    return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))

token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQ4MjEiLCJuYW1lIjoiQWFyYXYgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3ODc2MTYwMDAsImV4cCI6MTc4NzYxNjkwMH0.WAS6DgCOUxdAqOYytXIrrkojMUisVTJoCvUuDNZvytM'
header_b64, payload_b64, sig_b64 = token.split('.')
print(b64u_decode(header_b64))
print(b64u_decode(payload_b64))
expected = base64.urlsafe_b64encode(
    hmac.new(b'dev-secret-do-not-ship-this',
             (header_b64 + '.' + payload_b64).encode(),
             hashlib.sha256).digest()).rstrip(b'=').decode()
print('signature valid:', expected == sig_b64)

Three things this exercise teaches you that no diagram can:

  1. The payload is readable by anyone. Base64url is an encoding, not encryption. Anyone holding the token can decode the payload — so never put passwords, phone numbers, or marksheets in claims. Keep claims to identifiers and roles.
  2. The signature is what matters. Change one character of the payload and the HMAC no longer matches; the server rejects the token. Integrity and authenticity come from the signature alone.
  3. exp is a Unix timestamp. Here exp - iat = 900 seconds: a 15-minute access token. Short lifetimes are a deliberate design choice, explained below.

Interview-ready line: a JWT is a signed assertion of identity, not an encrypted envelope. Verification is a cryptographic check, not a database lookup.

HS256 vs RS256: picking a signing algorithm

The header's alg field names the signing algorithm. For student projects the choice is between two:

HS256 RS256
Type Symmetric (HMAC-SHA256) Asymmetric (RSA signature)
Keys One shared secret signs and verifies Private key signs, public key verifies
Speed Very fast Slower, still trivial at student scale
Fits when One backend service issues and verifies its own tokens Separate services verify tokens, or a third party needs to verify without being able to mint
Main risk Anyone holding the secret can forge tokens — guard it like a password Key management is more work: two keys, rotation story

Decision rule: if a single Node backend both issues and verifies tokens — the standard final-year setup — HS256 with a long random secret stored in an environment variable is the correct, honest choice. Reach for RS256 only when a genuinely separate service must verify tokens without being trusted to create them.

Generate the secret properly — never invent one by typing on the keyboard:

openssl rand -base64 64

Put the output in a .env file (JWT_SECRET=...), add .env to .gitignore, and load it with dotenv. A secret committed to a public GitHub repo is a compromised secret; rotating it means every issued token dies, which is exactly the outage you want to avoid during your demo.

The complete login flow, step by step

Here is the full lifecycle of a login in a JWT-based project, with the actual HTTP involved:

  1. Register — POST /api/auth/register with email and password. The server hashes the password with bcrypt (cost factor 10–12) and stores only the hash. Plaintext passwords in a database are a project-failing defect, not a shortcut.
  2. Login — POST /api/auth/login with email and password. The server compares the password against the bcrypt hash, and on success issues two tokens: a short-lived access token (15 minutes) and a long-lived refresh token (7 days, stored hashed server-side).
  3. Store — the client keeps the tokens according to the storage rules in the section below.
  4. Call APIs — every protected request carries the access token:
    curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." https://your-api.example.com/api/profile
    
  5. Verify — middleware on the server checks the signature, checks exp, and attaches the decoded claims (req.user) for the route handler. No database hit.
  6. Refresh — when the access token expires, the client sends the refresh token to POST /api/auth/refresh and receives a brand-new pair. The old refresh token is invalidated — this is rotation.
  7. Logout — the server deletes the refresh token; the access token simply expires within minutes. There is nothing to "unsign", which is why short access lifetimes matter.

The login exchange looks like this from the client's side:

curl -X POST https://your-api.example.com/api/auth/login   -H "Content-Type: application/json"   -d '{"email":"aarav@example.com","password":"correct-horse-battery"}'

and a healthy response:

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "dGhpcyBpcyBvcGFxdWUgbG9uZ2VyIGFuZCBvbmx5IHVzZWQgb25jZQ...",
  "user": { "id": "user_4821", "name": "Aarav Sharma", "role": "student" }
}

A working Node.js implementation

This is a minimal but complete implementation using Express, jsonwebtoken, and bcryptjs (the pure-JavaScript bcrypt — it avoids the native compilation headaches bcrypt causes on student laptops, at a small speed cost that does not matter at project scale). Install with npm install express jsonwebtoken bcryptjs dotenv.

Login route — verify password, issue tokens:

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const JWT_SECRET = process.env.JWT_SECRET; // 64+ random chars, from .env
const ACCESS_TTL = '15m';
const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000;

function signAccessToken(user) {
  return jwt.sign(
    { sub: user.id, role: user.role, name: user.name },
    JWT_SECRET,
    { algorithm: 'HS256', expiresIn: ACCESS_TTL }
  );
}

function newRefreshToken() {
  return crypto.randomBytes(48).toString('hex'); // 96 hex chars, opaque
}

app.post('/api/auth/login', async (req, res) => {
  const user = await db.users.findByEmail(req.body.email); // your data layer
  if (!user) return res.status(401).json({ error: 'invalid credentials' });

  const ok = await bcrypt.compare(req.body.password, user.passwordHash);
  if (!ok) return res.status(401).json({ error: 'invalid credentials' });

  const accessToken = signAccessToken(user);
  const refreshToken = newRefreshToken();
  // store only a SHA-256 hash of the refresh token, with expiry
  await db.refreshTokens.save({
    userId: user.id,
    tokenHash: crypto.createHash('sha256').update(refreshToken).digest('hex'),
    expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
  });

  res.json({
    accessToken: accessToken,
    refreshToken: refreshToken,
    user: { id: user.id, name: user.name, role: user.role },
  });
});

Two deliberate choices worth defending in your viva: the error message is identical for "unknown email" and "wrong password" (different messages leak which emails are registered), and refresh tokens are stored as hashes — a database leak then does not hand attackers live sessions.

Auth middleware — verify on every protected route:

function requireAuth(req, res, next) {
  const token = (req.headers.authorization || '').replace('Bearer ', '');
  if (!token) return res.status(401).json({ error: 'login required' });
  try {
    req.user = jwt.verify(token, JWT_SECRET); // { sub, role, name, iat, exp }
    next();
  } catch (err) {
    return res.status(401).json({ error: 'session invalid, please log in again' });
  }
}

app.get('/api/profile', requireAuth, async (req, res) => {
  const user = await db.users.findById(req.user.sub);
  res.json({ id: user.id, name: user.name, role: user.role });
});

Note algorithms: ['HS256'] passed explicitly — this pins the expected algorithm and blocks algorithm-confusion attacks where a forged token claims alg: none.

Refresh route — rotation with reuse detection:

app.post('/api/auth/refresh', async (req, res) => {
  const presented = req.body.refreshToken;
  if (!presented) return res.status(401).json({ error: 'missing refresh token' });
  const hash = crypto.createHash('sha256').update(presented).digest('hex');
  const stored = await db.refreshTokens.findByHash(hash);

  if (!stored || stored.expiresAt < new Date()) {
    // Either never issued, already rotated, or expired.
    // If it was already rotated, someone may be replaying a stolen token:
    // safest response is to revoke the whole token family for that user.
    if (stored && stored.used) await db.refreshTokens.revokeAllForUser(stored.userId);
    return res.status(401).json({ error: 'refresh token invalid' });
  }

  const user = await db.users.findById(stored.userId);
  await db.refreshTokens.markUsed(hash);           // invalidate the old one
  const accessToken = signAccessToken(user);
  const refreshToken = newRefreshToken();
  await db.refreshTokens.save({
    userId: user.id,
    tokenHash: crypto.createHash('sha256').update(refreshToken).digest('hex'),
    expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
  });
  res.json({ accessToken: accessToken, refreshToken: refreshToken });
});

Reuse detection is the mechanism that makes refresh-token theft detectable: if an attacker uses a stolen refresh token, the legitimate client's next refresh attempt finds its token already marked used, and the server burns the whole family. The legitimate user gets logged out — annoying, but the alternative is a silent account takeover.

Access tokens vs refresh tokens: why two tokens exist

Access token Refresh token
Format JWT (self-contained, verifiable without DB) Opaque random string (meaningless without DB)
Lifetime Short: 5–15 minutes Long: 7–30 days
Sent on Every API request Only to /refresh
Revocable instantly? No — valid until exp Yes — delete the DB row
If stolen Attacker has minutes of access Attacker has days — hence hashing, rotation, reuse detection

The design logic: the token that travels everywhere (access) dies quickly, so theft buys little; the token that lives long (refresh) travels rarely and is guarded by server-side state. One token trying to do both jobs does neither well.

Where to store tokens: the decision students get wrong most often

Storage Survives reload? XSS risk CSRF risk Verdict
localStorage / sessionStorage Yes / tab-only High — any injected script in your page can read it None — not auto-sent Convenient but fragile; acceptable only with rigorous XSS hygiene
httpOnly cookie (Secure, SameSite=Lax) Yes Low — JavaScript cannot read it Present — needs CSRF defense The right default when frontend and API share an origin
In-memory JS variable No Low (gone on reload) None Safest at runtime; pair with silent refresh on load

Two attacks, precisely distinguished:

  • XSS (cross-site scripting) steals tokens from localStorage. If an attacker can get script content running in your page — through an unsanitised comment field, a malicious npm package, or an event-handler attribute someone pasted into a profile bio — that script reads localStorage.getItem('token') and sends it home. httpOnly cookies are invisible to such scripts, which is why they are the recommended default.
  • CSRF (cross-site request forgery) abuses cookies. The browser automatically attaches cookies to requests, so a malicious site can trigger a POST to your API in the victim's browser with their cookies attached. Defenses: SameSite=Lax or Strict (modern browsers then withhold the cookie on cross-site requests), plus an anti-CSRF token for state-changing endpoints.

Practical rule for your project: if your frontend is served by the same backend (one origin), use httpOnly, Secure, SameSite=Lax cookies and add CSRF tokens on mutations. If the frontend and API live on different origins, keep the access token in memory, the refresh token in a Secure; SameSite=None cookie or guarded storage, and implement silent refresh. Either way, serve everything over HTTPS in production — a token sent over plain HTTP is readable by anyone on the network, and no storage choice survives that.

Revocation: the one thing JWTs cannot do

A signed token is valid until its exp, period — there is no "unsign" operation. Three honest strategies, in increasing strength:

  1. Short access lifetimes (5–15 min). Logout and password-change then take effect within minutes. For most student projects, this plus refresh-token deletion is sufficient and honest.
  2. Refresh-token blocklist. Deleting the refresh row logs the user out fully on next refresh. This is your real logout mechanism.
  3. Token version claim. Add tv: <n> to access tokens, store the current version per user, bump it on password change or admin ban. Middleware rejects tokens with a stale version — one indexed DB read per request. This gives you near-instant revocation while keeping verification cheap.

What not to do: maintain a blocklist of every revoked access token and check it on each request. At that point you have rebuilt server sessions with extra steps and lost the stateless benefit that justified JWTs.

Reading the error messages: a diagnosis table

When auth breaks, the library tells you exactly what is wrong — if you read the messages instead of guessing:

Symptom Likely cause Check
TokenExpiredError: jwt expired on every call Access lifetime elapsed; client never refreshes, or server clock skew Confirm the client calls /refresh; check iat/exp by decoding the token
JsonWebTokenError: invalid signature Secret differs between signing and verifying — e.g. .env loaded in one process but not the other, or staging vs production secret Print (locally, never in logs) the first 4 chars of the secret in each process and compare
JsonWebTokenError: jwt malformed Token truncated in transit, or the Bearer prefix missing/doubled Log the received header length; compare with what the client sent
Login succeeds but every protected route 401s Middleware not mounted, or the client sends the token in the body/query instead of the header Check route order in Express; inspect the outgoing Authorization header
Refresh works once, then loops forever New refresh token not stored/returned, or old one not invalidated Trace one full refresh cycle with logs at each DB step
Token verifies but role check rejects Claim name mismatch (role vs roles), or stale claims issued before a role change Decode the token and read the actual claim names

Security checklist for your project report

  • Passwords hashed with bcrypt (cost factor 10–12), never stored or logged in plaintext.
  • JWT secret is 64+ random characters from openssl rand, in .env, never committed to git.
  • algorithms: ['HS256'] pinned in every jwt.verify call.
  • Access tokens expire in 15 minutes or less; refresh tokens rotate on every use.
  • Refresh tokens stored as SHA-256 hashes with expiry; reuse triggers family revocation.
  • Identical login error for unknown email and wrong password.
  • Tokens sent only over HTTPS; cookies flagged Secure and HttpOnly.
  • No sensitive data (passwords, phone numbers, internal IDs you would not show the user) in token claims.
  • Authorization: Bearer parsing rejects malformed headers instead of crashing.

Where JWT fits in real student projects

JWT auth is the login layer under a large share of web projects: a secure password manager pairs bcrypt-hashed master credentials with short-lived access tokens; a real-time encrypted chat application authenticates both its REST calls and its socket connections with the same bearer token; an online examination system leans on the role claim to separate student routes from admin routes without extra database lookups. More web-build concepts in this space live in the Computer / IT branch hub.

Token size, clock skew and key rotation: the details that bite later

Three production details that student projects usually meet for the first time during the demo:

  • Token size travels with every request. Each claim adds bytes, and the access token rides along on every API call in the Authorization header. A 2 KB token on a slow hostel network is noticeable overhead across dozens of requests — keep payloads lean (identifiers and roles only) and target under ~1 KB. If you find yourself stuffing user preferences into claims, that data belongs behind an API endpoint, not in the token.
  • Clock skew breaks expiry checks. iat and exp are absolute Unix timestamps, so a server whose clock has drifted a few minutes ahead will reject tokens that are legitimately fresh. Keep server time synced with NTP, and pass a small leeway (for example 30 seconds) to jwt.verify so minor drift does not fail logins. In development this surfaces as the classic "works on my machine, 401 on the deployed server" mystery.
  • Plan key rotation before you need it. If your secret ever leaks, every outstanding token must die. The clean mechanism is a kid (key id) header claim: the server holds a small map of key ids to secrets, signs new tokens with the current key, and during a rotation window accepts both old and new. Student projects can keep this simple — one active key plus one previous key — but having the kid plumbing from day one turns a leak from a crisis into a config change.

Putting it together

The mental model to carry into your viva: the server signs a tamper-evident assertion of identity at login; the client presents it with each request; the server verifies the signature cryptographically instead of consulting a session table. Short-lived access tokens bound the damage of theft, rotating refresh tokens make theft detectable, httpOnly cookies blunt XSS, and CSRF tokens blunt the cookie's own weakness. Build the implementation above once, by hand, and "how does login work in your project" becomes the easiest question in the room.

More project guides

More in Computer / IT

SQL vs NoSQL for Final-Year Projects: Which Database Should You Pick?

MySQL or MongoDB for your final-year project? SQL databases store data in related tables with enforced schemas, joins and transactions — the right default when your data is structured and money or records must stay consistent. NoSQL document stores trade the rigid schema for flexible, nested documents that ship faster when your data shape keeps changing. This guide compares them with a worked hospital-appointment example in both, a decision table, and rules matched to common project archetypes.

Read guide

Docker for Student Projects: Images, Containers and Compose from Zero

End ‘it works on my machine’ failures: learn what Docker images and containers actually are, write lean Dockerfiles that exploit layer caching, persist data with volumes, orchestrate app-plus-database with Compose, and package an evaluator-proof submission — with the debugging table for every error you will definitely meet.

Read guide

Smart India Hackathon: Problem-Statement Analysis, Team Roles and the 36-Hour Build Plan

How do you actually win — or at least survive with something demoable — at the Smart India Hackathon? Preparation beats talent: pick a problem statement you can scope to 36 hours, assign six clear team roles, freeze your tech stack weeks before the finale, and build to a ruthless hour-by-hour plan. This guide covers the SIH format, a scoring matrix for choosing problem statements, the 36-hour build schedule, git workflow for six people, demo-day backup plans, and what winning teams do differently.

Read guide