Bearer Token: The Two Things Hiding In One HTTP Header
Authorization: Bearer eyJhbGciOi… is not one thing, it is two — a scheme and a credential. Here is what the word Bearer actually promises, why the token comes in exactly two flavours (opaque and JWT) and what that choice costs you, why OAuth 2.0 is not a third flavour, and the three pieces of code that issue it, send it and verify it.
You have seen this line in almost every API request you have ever made:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
Most people read it a hundred times without noticing the thing that makes it make sense: it is not one thing. It is two.
Bearer is one thing. The long string after it is a completely different thing. They are not two halves of a single word, and once you see the split, every confusing question about tokens — is a JWT a bearer token? is OAuth a kind of JWT? why can I decode my own token in a browser tab? — answers itself.
The split
The header has a scheme and a credential.
Authorization: Bearer eyJhbGciOi…
^^^^^^ ^^^^^^^^^^^
scheme credential
HOW WHAT
The scheme is the agreed way of saying who you are. It tells the server how to read the rest of the line. Bearer is not your token, does not vary per user, and is not secret — it is a fixed keyword, the same for every request your app ever sends.
The credential is the actual secret. That is the part that identifies you, that you must not leak, and that expires.
This is not a convention someone invented for JWTs. It is RFC 7235, the HTTP authentication framework: the Authorization header is always <scheme> <credentials>. Bearer is just one registered scheme among several.
The family
Basic → base64(user:password), sent on every request
Digest → a hashed challenge/response, so the password never crosses
Bearer → a token: whoever holds it, gets in
API key → a fixed key, usually per-application rather than per-user
Bearer is one of them. That is the whole relationship — nothing deeper.
There is a matching direction back, which is worth knowing because it is how a client discovers what to send: when you call a protected endpoint with no credentials, a well-behaved server answers 401 and names the scheme:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token"
The server is telling you which scheme it speaks. The client is expected to answer in that scheme.
What "Bearer" actually promises
The word is doing real work. A bearer instrument — a bearer bond, a cinema ticket, a banknote — belongs to whoever is physically holding it. No name on it, no second check.
A bearer token is exactly that:
Whoever holds this token, gets in.
There is no second factor inside the mechanism. The server does not verify that you are the one presenting it. It verifies that the token is valid, and then it acts on it. This is the entire security model, and every practical rule about tokens falls out of it:
- send them only over TLS, because anyone who reads one on the wire is you
- keep them short-lived, because expiry is the only thing that eventually stops a stolen one
- never put them somewhere that gets logged or shared
The upside is the reason the whole scheme exists: your password is not in the request. You send it once, at login. After that, every request carries a token that can be expired, scoped and revoked without the user ever changing their password.
The token comes in two flavours
Here is the split almost every explanation skips. Once a server hands you a token, the string can be one of exactly two kinds of thing.
1. Opaque
A random string. a7f3d9c1e04b…. It means nothing by itself — there is no information inside it, which is the point.
To find out who it belongs to, the server looks it up in its own store:
token a7f3d9… → user 4021, scopes [read,write], expires 21:40
Two consequences, and they are the whole trade-off:
- every request costs a lookup (usually Redis, sometimes Postgres)
- revocation is instant — delete the row and the token is dead on the very next request
One detail people miss: a good server does not store the token itself. It stores a hash of it, exactly the way it stores a password. If the token table leaks, the attacker gets hashes, not live credentials.
2. JWT
A JSON Web Token is self-contained. The data is inside it, and the whole thing is signed:
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiI0MDIxIiwiZXhwIjoxNzU1MTI… . 4f9Xk2mQ…
header payload signature
Three base64url segments joined by dots. The server does not look anything up — it verifies the signature with its key, and if that checks out, it trusts the payload it just read.
- verification is local and cheap — no database, no network
- you cannot un-issue it — a JWT is valid until it expires, full stop
🔴 Signed is not encrypted. Paste any JWT into a base64 decoder and you can read the payload — that is not a vulnerability, it is the design. The signature guarantees nobody changed it; it does nothing to hide it. Never put anything secret in a JWT payload.
Choosing between them
| opaque | JWT | |
|---|---|---|
| server work per request | a lookup | a signature check |
| revoke right now | yes, delete the row | no, wait for expiry |
| survives a service that has no DB access | no | yes |
| payload readable by the holder | nothing to read | yes — assume it is public |
The honest rule: if you need to kill a session immediately, opaque is the simpler answer. If you need many services to validate tokens without all sharing a session store, JWT earns its keep — and you buy back revocation with short expiries plus a refresh token, which is precisely why refresh tokens exist. A 15-minute access token means a stolen one is dead in 15 minutes; the long-lived refresh token stays in one place, is used rarely, and can be revoked because checking it is a lookup.
OAuth 2.0 is not a third flavour
This is the single most common mix-up, and it is a category error rather than a detail.
OAuth 2.0 does not sit next to opaque and JWT. It sits above them. It is the protocol that issues tokens — the dance of redirects, consent screens, authorization codes and client secrets that ends with a token in your hand. What it hands you may be opaque or may be a JWT; OAuth 2.0 does not care, and RFC 6750 — the spec for using bearer tokens — deliberately says nothing about the token's format.
OAuth 2.0 ──issues──▶ opaque
──issues──▶ JWT
So "we use OAuth instead of JWT" is not a sentence that means anything. "We use OAuth 2.0 to issue short-lived JWTs" is.
And you do not need OAuth to have bearer tokens. A plain email-and-password login that returns a signed token is using the Bearer scheme without a single line of OAuth in it — which is exactly what the code below does.
The whole thing in code
Three pieces, in the order the request actually travels: issue → send → verify.
1. The server issues it
# auth.py
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException
from jose import jwt
KEY = settings.jwt_key # only the server ever sees this
ALGO = "HS256"
router = APIRouter()
@router.post("/login")
def login(creds: LoginBody):
user = authenticate(creds.email, creds.password) # the ONLY time a password appears
if not user:
raise HTTPException(401, "bad credentials")
now = datetime.now(timezone.utc)
payload = {
"sub": str(user.id), # who the token is for
"iat": now, # when it was issued
"exp": now + timedelta(minutes=15), # and when it dies
}
return {"access_token": jwt.encode(payload, KEY, algorithm=ALGO),
"token_type": "bearer"}
Three things to notice. sub is the subject — the whole point of the token. exp is not optional garnish; it is the only brake on a leaked token. And KEY never leaves the server, which is what makes the signature mean anything.
2. The client sends it
This is the half that gets left out of most explanations, and it is the half that actually produces the header we started with:
// after login — keep the token
const res = await fetch("/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const { access_token } = await res.json();
// on every call after that — send it
await fetch("/me", {
headers: {
Authorization: `Bearer ${access_token}`,
},
});
That template string is the line at the top of this post. Bearer, a space, the credential.
Where do I keep it?
localStorageis convenient and readable by any script that ends up on your page, which makes XSS a full account takeover. AnhttpOnlycookie cannot be read by JavaScript, but rides along automatically and so needs CSRF protection. There is no free option — pick the failure you are prepared to defend against.
3. The server verifies it
# deps.py
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
bearer = HTTPBearer() # declare the scheme once
def current_user(cred: HTTPAuthorizationCredentials = Depends(bearer)):
try:
claims = jwt.decode(cred.credentials, KEY, algorithms=[ALGO])
except JWTError: # bad signature, expired, malformed — all one answer
raise HTTPException(401, "invalid token")
return int(claims["sub"])
# routes.py
@app.get("/me")
def me(user_id: int = Depends(current_user)):
return {"id": user_id}
HTTPBearer() is what parses Authorization: Bearer <x> and rejects any other scheme. Declare it once; every protected route just asks for the result.
On every request, the server does the same three things:
- reads the scheme — is this
Bearer, or something it does not speak? - applies that scheme's logic — for Bearer: verify the token, by lookup or by signature
- authorizes the request — now it knows who is calling
The mistakes worth naming
- Putting the token in a query string.
?token=eyJ…lands in access logs, proxy logs, browser history and theRefererheader of every outbound link. RFC 6750 permits it; do not do it. - Accepting
alg: none. The JWT header declares its own algorithm, and a library that trusts that field will happily accept an unsigned token. Always pass an explicit algorithm list todecode— thealgorithms=[ALGO]above is not decoration. - Reading claims before verifying.
jwt.decode(token, options={"verify_signature": False})shows up in debugging and then stays. Anything read before the signature check is attacker-controlled. - No expiry. A token without
expis a password that the user cannot change. - Storing raw tokens server-side. If you went opaque, store the hash. Your token table is a password table.
- Confusing this with permissions. Authentication answers who is calling. It says nothing about what they may do. A valid token for user 4021 is not permission to delete invoice 88 — that check is yours to write, separately, every time.
So, all of it
Bearer→ the scheme. How you send the credential.<token>→ the credential. Either opaque (looked up) or a JWT (verified).- OAuth 2.0 → the protocol that issues it. Not a third flavour.
- And the server never sees a password.
One header. Two parts. Now you can read it.