Opaque bearer tokens
A key card gets you into an office building. It has no name on it, no photo, and nothing readable inside. You tap it, a reader checks it against a list, and the door opens or it doesn't.
An opaque bearer token is that card. A random, unguessable string with nothing inside it to read.
Stateless does not mean the server forgets everything
The word trips people up, so it's worth pinning down first.
Stateless means the server keeps no session: no object tracking a user between requests. It still has a database, still has users, still stores things. What it stops holding is a record that this person is currently logged in.
The request has to bring everything the server needs to work out who is asking.
The server stops remembering you; the client starts proving itself every time.
Two words explain the name. Opaque means the server cannot read anything inside it, because there's nothing in there. It isn't encoded or structured, it's a random string. Bearer means whoever holds it, uses it.
The one thing it stops keeping is a note saying you're logged in right now. That note is what the token replaces.
The flow
Six steps, and the shape will look familiar:
- The user submits a username and password.
- The server checks them.
- On success, the server generates a random token.
- The server stores a small token-to-user mapping, then sends the token to the client.
- The client keeps it, and attaches it to every later request in the
Authorizationheader. - The server looks the token up, finds the user, and the request is authenticated.
GET /api/orders
Authorization: Bearer 7f3a9c1e5b28d4a06e91f7c3Compared with sessions and cookies, the differences sit in three places:
| Stateful session | Opaque bearer token | |
|---|---|---|
| The server stores | A full session object | A token-to-user row |
| The client stores | A cookie holding a session id | The token itself |
| It travels | Automatically, by the browser | Explicitly, in code you write |
| It suits | Traditional web apps | APIs and mobile apps |
That third row matters more than it looks. A cookie rides along without anyone asking. A bearer token is attached by your own client code, on every request, deliberately.
The difference is how much the server keeps and who does the sending. Same skeleton, different weight distribution.
Five ways it goes wrong
Almost none of the risk is in how the model works. It's in how the token is stored, how long it lasts, and how well the lookup is looked after.
| What goes wrong | Why it matters | The fix | |
|---|---|---|---|
| 1 | Unsafe storage | A token in localStorage, a plain variable or an unprotected cookie is readable by any script on the page. One XSS bug and the attacker is indistinguishable from the user | Keep it somewhere harder to reach, and limit how often it's exposed |
| 2 | Long-lived tokens | A token good for weeks means a leaked token is good for weeks | Short expiry windows |
| 3 | No revocation | Tokens don't expire on their own or stop working at logout. An expiry nothing checks is decoration | Check expiry on every request, delete on logout |
| 4 | Lookup bloat | Every login writes a row, so rows pile up and lookups slow. An attacker can force this by hammering the login endpoint, which is a denial of service | A scheduled job removing expired tokens |
| 5 | A tampered lookup | The mapping is the source of truth. Write access through SQL injection or leaked credentials repoints a token at another user | Protect the database and admin tooling as carefully as the app |
Pitfall 5 is the one worth sitting with. Nothing about the token changes; what it means changes. That's elevation of privilege achieved without touching the credential at all.
The token is fine. Everything around it is where the work is.
Try it
An API issues tokens like this:
const token = Math.random().toString(36).slice(2)
tokens[token] = { userId: 4821 }
res.json({ token })And checks them like this:
const token = req.headers.authorization?.replace('Bearer ', '')
const entry = tokens[token]
if (!entry) return res.status(401).json({ error: 'Unauthorized' })
req.userId = entry.userIdFind four problems.
Compare your answers
1. Math.random is not cryptographically secure. Its output is predictable from previous values, so an attacker who collects a few tokens can work out others. crypto.randomBytes(32).toString('hex') is the fix, and this one alone makes the whole scheme forgeable.
2. No expiry anywhere. Nothing is stored about when the token was issued and nothing checks it on the way in, so every token issued is valid forever. Store an expiry and check it on every request.
3. Tokens are stored in the clear. tokens[token] keeps the original string, so anyone who reads that store can use every token in it. Store a hash and look up by hashing what arrives.
4. tokens is a plain object, so the lookup is permeable. tokens['toString'] returns Object.prototype.toString, a truthy value, so a request bearing the literal token toString sails past the if (!entry) check.
entry.userId is then undefined. Whether that becomes a crash or an authenticated request as user undefined depends on the code after it. Use a Map, Object.create(null) or Object.hasOwn().
Number 4 is the one that reads as fine. The other three are things to remember; that one is a property of JavaScript objects that has shipped in real systems.
Where this goes next
The lookup is what makes this model workable and also what limits it. It gives you revocation, and it means every request touches shared storage, the very thing stateless identity was supposed to avoid.
JSON Web Tokens push the idea to its conclusion by removing the lookup entirely, putting the identity inside the token itself.

