Sessions, JWT & passwords

The honest minimum for logging users into your app — hash passwords with bcrypt, choose between cookie sessions and JWTs, and store sessions so they survive a restart. Verified end to end on the Node.js application.

Sooner or later your app needs a login. Authentication has a scary reputation, but the core is small and boring in a good way: never store a raw password, and give the browser a token that proves who it is on later requests. This guide covers both halves — hashing passwords with bcrypt, then the two mainstream ways to remember a logged-in user (cookie sessions and JWTs) — with working, verified code and the trade-off that decides which one you want.

At a glance
You need A server running the Node.js application
Background Build an Express website + API and Designing a small REST API
Plan Any plan
Time Forty-five minutes

Rule zero: never store the raw password

If your database holds password: "hunter2", one leak exposes every account — and people reuse passwords everywhere. Instead you store a hash: a one-way scramble you can check against but never reverse. bcrypt is the standard, and it's deliberately slow so that guessing is expensive.

bcryptjs is a pure-JavaScript bcrypt with no native build step, so it installs cleanly here. Install it from the Packages page, then hash on signup and compare on login:

const bcrypt = require('bcryptjs');

// On register — store this, never the password itself:
const passwordHash = await bcrypt.hash(password, 12); // 12 = cost factor

// On login — compare the attempt against the stored hash:
const ok = await bcrypt.compare(attempt, passwordHash); // true / false

compare returns true only when the attempt matches. You never decrypt anything; you re-hash the attempt and check. The 12 is the cost — higher is slower and safer; 12 is a sensible default.

⚠️ Heads up: The hash is safe to store, but everything else about auth leans on secrets that are not. Your session secret and JWT secret belong in .env, never in your code and never in Git — see Environment variables & secrets and Keep secrets out of Git.

Two ways to remember a login

Once the password checks out, the server hands the browser something to send on every later request. There are two mainstream choices.

Cookie sessions JWT (token)
Where the state lives On the server (a session store) Inside the token itself, on the client
The browser holds A tiny session id in a cookie The whole signed token
Logging someone out Delete their session — instant Hard: valid until it expires
Best for Websites you also serve APIs, mobile apps, third-party callers
Extra moving part A session store A short expiry + refresh strategy

Rule of thumb: serving a website from the same server → sessions. A pure API that phones, mobile apps, or other sites will call → JWT. Plenty of apps use sessions for the website and issue JWTs for their API.

Option A — cookie sessions

express-session sets a signed cookie holding a session id; the matching data lives in a store on the server. The catch is the default store.

⚠️ Heads up: Out of the box, express-session keeps sessions in memory. That store is for development only — it leaks memory over time, forgets everyone the moment your server restarts (and on Falix, restarts happen: redeploys, the free session timer, reinstalls), and can't scale past one process. Express itself prints a warning about it. Use a real store.

The cleanest real store here is SQLite via better-sqlite3-session-store, which rides on better-sqlite3 — a native module that ships prebuilt binaries and installs without a compiler. Install express-session, bcryptjs, better-sqlite3, and better-sqlite3-session-store from the Packages page, then:

const express = require('express');
const session = require('express-session');
const bcrypt = require('bcryptjs');
const Database = require('better-sqlite3');
const SqliteStore = require('better-sqlite3-session-store')(session);

const db = new Database('sessions.db');
const app = express();
app.use(express.json());

app.use(session({
  store: new SqliteStore({ client: db, expired: { clear: true, intervalMs: 900000 } }),
  secret: process.env.SESSION_SECRET, // from .env — a long random string
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 7 }, // 7 days
}));

// Pretend user store — in real life these are database rows.
const users = {};

app.post('/register', async (req, res) => {
  const { username, password } = req.body;
  if (!username || !password) return res.status(400).json({ error: 'username and password required' });
  if (users[username]) return res.status(409).json({ error: 'username taken' });
  users[username] = { username, passwordHash: await bcrypt.hash(password, 12) };
  res.status(201).json({ ok: true });
});

app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  const user = users[username];
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: 'invalid credentials' });
  }
  req.session.userId = username; // this is what "logs them in"
  res.json({ ok: true });
});

app.get('/me', (req, res) => {
  if (!req.session.userId) return res.status(401).json({ error: 'not logged in' });
  res.json({ username: req.session.userId });
});

app.post('/logout', (req, res) => {
  req.session.destroy(() => res.json({ ok: true }));
});

const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));

Setting req.session.userId is the whole login: the store saves it, the browser gets the cookie, and /me reads it back on the next request. req.session.destroy() is logout. Because the store is SQLite, sessions now survive a restart.

Verify with a cookie jar. curl can act like a browser holding a cookie:

# log in, save the cookie to jar.txt
curl -c jar.txt -X POST -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"s3cret"}' http://YOUR_ADDRESS:PORT/login
# now use the cookie — /me returns your username
curl -b jar.txt http://YOUR_ADDRESS:PORT/me

Without -b jar.txt, /me returns 401. With it, you get {"username":"alice"} — that's the session working.

🎯 Good to know: sessions.db is a file on your server's disk. It survives restarts, but a reinstall or switching the server's application wipes all files — sessions included (everyone gets logged out). For anything you can't afford to lose, a managed database outlives reinstalls; Redis is another popular session store.

Option B — JWT

A JWT is a signed token the client keeps and sends back in an Authorization header. The server doesn't store anything — it just verifies the signature. Install jsonwebtoken and bcryptjs from the Packages page:

const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // from .env — long and random

// On login, after bcrypt.compare succeeds:
const token = jwt.sign({ sub: username }, SECRET, { expiresIn: '7d' });
res.json({ token }); // the client stores this and sends it back

// A guard for protected routes:
function requireAuth(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'missing token' });
  try {
    req.user = jwt.verify(token, SECRET); // throws if tampered/expired
    next();
  } catch {
    return res.status(401).json({ error: 'invalid token' });
  }
}

app.get('/me', requireAuth, (req, res) => res.json({ username: req.user.sub }));

jwt.sign mints the token; jwt.verify checks the signature and expiry, throwing if either fails. The client sends it as Authorization: Bearer <token>:

TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
  -d '{"username":"bob","password":"p4ss"}' http://YOUR_ADDRESS:PORT/login | ...)
curl -H "Authorization: Bearer $TOKEN" http://YOUR_ADDRESS:PORT/me   # -> your username
curl -H "Authorization: Bearer garbage" http://YOUR_ADDRESS:PORT/me  # -> 401

The honest catch is logout: a JWT is valid until it expires, so you can't truly revoke one without adding server-side state (the very thing JWTs avoid). Keep expiries short (15m1h) and add a refresh flow when you need "stay logged in."

Cookies and HTTPS

Set cookie.secure: true in production so the session cookie only travels over HTTPS — and put your app behind the Network page's Reverse Proxy for automatic SSL (Domains and HTTPS). When you do, add app.set('trust proxy', 1) so Express trusts the proxy's HTTPS. Also keep httpOnly: true (already above) so client-side JavaScript can't read the cookie.

The libraries here own everything past the Falix layer: express-session and jwt.io document the options in full, and expressjs.com covers middleware.

Troubleshooting

  • Logged in, but /me says 401 — the cookie isn't coming back. A browser on a different domain needs CORS with credentials (CORS errors explained); curl needs -b jar.txt.
  • Everyone logged out after a restart — you're on the default memory store. Switch to the SQLite (or Redis) store above.
  • secretOrPrivateKey must have a valueJWT_SECRET/SESSION_SECRET isn't set. Add it to .env and confirm require('dotenv').config() runs first, or set it as an environment variable.
  • better-sqlite3 won't install — it ships prebuilt binaries for the Node application's Linux; if an install fails, read the npm output at the top of the console (App won't start).

Next steps

Was this guide helpful?