Skip to content

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.

JunoStateless does not mean the server forgets everything "Stateless" sounds like the server has amnesia, and it doesn't. It knows everything about your account.

The one thing it stops keeping is a note saying you're logged in right now. That note is what the token replaces.

JunoStateless does not mean the server forgets everything This model is only partly stateless, and it's worth saying so directly. The server still keeps a token-to-user lookup, so the state didn't vanish, it shrank.

A session object holds identity, a role and whatever else accumulated. The lookup holds one row: this string means this user. That's a much smaller thing to store, replicate and keep in sync.

JunoStateless does not mean the server forgets everything The header name is a genuine leftover and it misleads constantly. The token travels in Authorization: Bearer <token>, and in this model it does authentication: it proves who you are and grants nothing by itself. Permissions still get decided afterwards from the user it resolves to.

Worth remembering in an unfamiliar codebase, because the header name invites the assumption that possession implies permission. It never does, and the second check is the one people forget to write.

The flow

Six steps, and the shape will look familiar:

  1. The user submits a username and password.
  2. The server checks them.
  3. On success, the server generates a random token.
  4. The server stores a small token-to-user mapping, then sends the token to the client.
  5. The client keeps it, and attaches it to every later request in the Authorization header.
  6. The server looks the token up, finds the user, and the request is authenticated.
http
GET /api/orders
Authorization: Bearer 7f3a9c1e5b28d4a06e91f7c3

Compared with sessions and cookies, the differences sit in three places:

Stateful sessionOpaque bearer token
The server storesA full session objectA token-to-user row
The client storesA cookie holding a session idThe token itself
It travelsAutomatically, by the browserExplicitly, in code you write
It suitsTraditional web appsAPIs 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.

JunoThe flow If this feels a lot like sessions and cookies, that's a fair reaction. Something is stored by the client, something is checked by the server, and a lookup connects them.

The difference is how much the server keeps and who does the sending. Same skeleton, different weight distribution.

JunoThe flow Sending the token explicitly is a feature rather than a chore. A cookie attaches itself to every request to that domain, including ones triggered by another site. That is what makes CSRF possible in the first place.

A bearer token is attached by your code, so a request your code didn't make carries nothing. That's why this model is the default for APIs served to clients you don't control.

JunoThe flow Step three carries the requirement people skip. The token must come from a cryptographically secure source, crypto.randomBytes and not Math.random, with enough length that guessing is hopeless. 32 bytes is the usual floor.

Step four has one worth adopting: store a hash of the token, not the token. The client keeps the original, the server keeps a digest, and looks up by hashing what arrives.

The lookup table is then useless to anyone who reads it, for the same reason passwords are never stored in the clear. Plenty of production systems keep bearer tokens in plain text, and it's the first thing an attacker with database access harvests.

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 wrongWhy it mattersThe fix
1Unsafe storageA 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 userKeep it somewhere harder to reach, and limit how often it's exposed
2Long-lived tokensA token good for weeks means a leaked token is good for weeksShort expiry windows
3No revocationTokens don't expire on their own or stop working at logout. An expiry nothing checks is decorationCheck expiry on every request, delete on logout
4Lookup bloatEvery 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 serviceA scheduled job removing expired tokens
5A tampered lookupThe mapping is the source of truth. Write access through SQL injection or leaked credentials repoints a token at another userProtect 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.

JunoFive ways it goes wrong Notice that four of the five have nothing to do with the token itself. It's where it's kept, how long it lasts, whether anyone tidies up, and who can edit the list.

The token is fine. Everything around it is where the work is.

JunoFive ways it goes wrong Pitfall one has no clean answer, and it helps to know why, because a lot of advice online pretends otherwise.

localStorage is readable by any script on the page. A cookie with HttpOnly is not, and cookies bring CSRF back with them, so you need SameSite and possibly a token check.

Both are real designs with real weaknesses. The question is which attack you'd rather defend against, and the honest answer usually starts with not having an XSS bug.

JunoFive ways it goes wrong The shape production settles on is a short-lived access token, minutes, plus a long-lived refresh token stored somewhere harder to reach. The access token gets checked constantly and expires quickly; the refresh token is presented rarely and can be revoked.

That reintroduces a lookup, on refresh instead of on every request. It's the compromise worth understanding: you are buying back revocation at a fraction of the cost.

Refresh token rotation is the addition that makes theft detectable. Issue a new refresh token on every use and invalidate the old one, so a stolen token being replayed shows up as a reused token, and the whole family can be revoked at that moment.

Try it

An API issues tokens like this:

js
const token = Math.random().toString(36).slice(2)
tokens[token] = { userId: 4821 }
res.json({ token })

And checks them like this:

js
const token = req.headers.authorization?.replace('Bearer ', '')
const entry = tokens[token]
if (!entry) return res.status(401).json({ error: 'Unauthorized' })
req.userId = entry.userId

Find 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.