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.

Banner

Prefer to watch? ⚡ The 2-minute version ✈ Telegram

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

opaqueJWT
server work per requesta lookupa signature check
revoke right nowyes, delete the rowno, wait for expiry
survives a service that has no DB accessnoyes
payload readable by the holdernothing to readyes — 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? localStorage is convenient and readable by any script that ends up on your page, which makes XSS a full account takeover. An httpOnly cookie 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:

  1. reads the scheme — is this Bearer, or something it does not speak?
  2. applies that scheme's logic — for Bearer: verify the token, by lookup or by signature
  3. 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 the Referer header 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 to decode — the algorithms=[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 exp is 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.

Bearer Token: The Two Things Hiding In One HTTP Header | Software Engineer Blog