Authentication: sessions vs JWT
How Linkstash's opaque session tokens actually work end to end, and the real tradeoff against JWTs: revocability versus a database lookup on every request.

Log in once, then every request after that needs to prove who you are without asking for a password again. There are two mainstream ways to do that, and Linkstash picks one deliberately: opaque session tokens, not JWTs. Here's the whole mechanism, and then the actual tradeoff, not the version of it that gets repeated without much thought.
How Linkstash does it
Login hands back a token:
app.post("/auth/login", async (req, res) => {
const parsed = credentials.safeParse(req.body);
if (!parsed.success) {
res.status(401).json({ error: "Unauthorized" });
return;
}
const user = await verifyUser(db, parsed.data.email, parsed.data.password);
if (!user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
res.json({ token: createSession(db, user.id), user });
});createSession is where the token actually gets made:
function ensureSessions(db: Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
userId INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
createdAt TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
}
export function createSession(db: Database, userId: number): string {
ensureSessions(db);
const token = randomBytes(32).toString("hex");
db.prepare("INSERT INTO sessions (token, userId) VALUES (?, ?)").run(token, userId);
return token;
}randomBytes(32).toString("hex") produces a 64-character random string with no structure to it at all, no embedded user id, no timestamp, no signature to decode. It's a lookup key and nothing else, stored in a sessions table alongside the userId it belongs to. On every subsequent request, requireAuth takes the Bearer token off the header and asks the database what it means:
export function userIdForToken(db: Database, token: string): number | null {
ensureSessions(db);
const row = db.prepare("SELECT userId FROM sessions WHERE token = ?").get(token) as { userId: number } | undefined;
return row ? row.userId : null;
}Notice the token itself proves nothing on its own. It's a random string, matched against a table row. If there's no row, userIdForToken returns null and requireAuth responds with 401, no matter how plausible the token string looks. This is what "opaque" means: the token carries zero information, the server-side lookup is the entire source of truth.
What a JWT would look like instead
A JWT (JSON Web Token) takes the opposite approach: instead of a random string pointing at a database row, it's a signed, self-contained blob, typically { userId: 7, exp: 1735689600 } encoded and cryptographically signed with a secret only the server knows. A request comes in with a JWT, the server verifies the signature, and if it checks out, it trusts the payload directly. No database lookup needed, because the token isn't a pointer to data, it is the data, with a signature proving nobody tampered with it.
That sounds like a straightforward win for JWTs: skip the database round trip on every single authenticated request. And for a specific shape of system, it is a real win. It's just not the whole story.
The actual tradeoff: revocation
Here's the question that decides it. A user's account gets compromised, or they just want to log out everywhere. Can you kill that specific session right now?
With Linkstash's opaque tokens, yes, trivially: DELETE FROM sessions WHERE token = ?, and that token stops working on the very next request, because userIdForToken looks it up fresh every time. With a JWT, the answer is genuinely awkward. The whole appeal of a JWT is that the server doesn't look anything up, it trusts the signature. Which means there's nothing to delete. The token stays valid until it expires on its own, no matter what the server wants, unless you build a separate revocation list to check against, at which point you've reintroduced a database lookup on every request and given up the one thing JWTs were supposed to buy you.
That's the real tradeoff, not "JWTs are stateless and sessions aren't" as an abstract fact, but a concrete question: does immediate revocation matter for what you're building? Linkstash decides it does. A bookmark manager where a stolen token could delete or read someone's saved links is exactly the kind of thing where "log out everywhere, instantly" needs to actually work, not "log out everywhere, eventually, once tokens expire." A single-lookup cost on every request is a small price for that guarantee, especially against SQLite, which answers a primary-key lookup in microseconds.
When a JWT is the better call
JWTs earn their keep in systems where the token needs to cross a trust boundary without a shared database to check against, service-to-service auth between separately-deployed systems, or short-lived tokens (minutes, not days) where "can't revoke early" barely matters because it expires on its own soon anyway. For a single API with its own session store sitting right there, an opaque token is simpler and strictly more revocable.
One more thing sessions need: how they're stored client-side
This lesson has been about the token's shape, not where the client keeps it. Authorization: Bearer <token> on every request means the client (a browser app, typically) has to hold onto that string somewhere between requests, and how it holds onto it (a cookie versus localStorage) has its own security tradeoffs that deserve their own treatment. Cookies and how sites remember you covers exactly that.
Quick check
Why can Linkstash revoke a stolen session token instantly, while a stolen JWT typically stays valid until it expires?
Sessions answer "is this a valid token." They don't answer the harder question underneath: how does verifyUser actually check a password without leaking who has an account, just by how long it takes to respond? That's next: user accounts and password hashing done right.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


