Skip to content
KEDBYTE
How Identity Works
Chapter
38

The Token

Part IV · Identity Between Systems|12,044 words|about 52 min read|Volume 4

38.0 What this chapter gives you#

  1. You will be able to state, in the words of the specification, what makes a token a bearer token, and say what that property costs you.
  2. You will be able to choose between an opaque token and a structured one, and defend the choice on revocation, latency and privacy grounds.
  3. You will be able to set an access token lifetime against a real risk, and say what the number buys and what it does not.
  4. You will be able to implement refresh token rotation with reuse detection, and say what the server must do the moment a rotated token is presented twice.
  5. You will be able to explain the difference between a token bound to a certificate and one bound to an application key, name the specification behind each, and say when each is right.
  6. You will be able to say what the issuer, audience and scope fields each prevent, and name the attack that becomes possible when each is missing.
  7. You will be able to list the seven routes by which tokens escape, give a real incident for most, and name the control that closes each.
  8. You will be able to revoke a token that was never written down anywhere, size the deny-list this needs in megabytes, and explain why it is a compromise rather than a solution.
  9. You will be able to read a token exchange request, follow a delegation chain three services deep, and say who is accountable at each hop.
  10. You will be able to fill in a table of token types against theft consequence and mitigation for your own system, and know which row will hurt you.

A token is anything you hand over that stands in for something else. A cloakroom ticket stands in for a coat, a bus token for a fare, a casino chip for money. None of them is the thing itself, and all of them work because the person receiving them agreed in advance what they mean.

In software, a token stands in for a decision somebody already made about you. At some earlier moment a system checked a password, a fingerprint or a hardware key and concluded that you are who you say you are and may do certain things. Repeating that check on every request would be slow, and in most architectures impossible, because the service handling the request is not the service that did the checking. So the checking service writes the decision down, hands it over, and from then on the token is presented instead of the evidence.

The whole chapter turns on one word in the specification that governs how these things travel on the web. That word is bearer. A bearer token is one where possession is the entire proof: the system does not ask who is presenting it or how they came by it, only whether it is valid, and if it is, it does what the token permits. That is why bearer tokens are everywhere, being trivial to use and needing no cryptography in the client, and it is also why they are the most stolen credential in modern software. A password has to be typed into something before it is useful. A token in a log file is already useful.

The chapter before this one dealt with the cookie, the small named value a browser stores and returns automatically, and the attributes that decide when it may travel; that is chapter 37. The chapter after deals with the JSON Web Token, the structured format most tokens now use, and the long list of ways implementations get it wrong; that is chapter 39. This chapter covers the idea itself: what it means for a credential to be a bearer credential, what you can do to make one less dangerous, and what you cannot. One worked example runs the whole way through: a customer called Meera at a bank called Northbank, and a budgeting application called Ledgerly that she has allowed to read her account.

The plain version#

The hotel that never looks at your face#

Imagine a hotel. You arrive, you show your passport at the front desk, you pay, and the clerk hands you a plastic card. From that moment on, nobody in the building ever looks at you again. You walk to the lift and the card opens it. You walk to room 412 and the card opens it. You go down to the pool at eleven at night and the card opens the gate. At no point does a person check your face, your name or your booking. The doors check the card.

This is the arrangement that runs almost all of modern software, and it is worth sitting with how strange it is. The hotel did an identity check once, carefully, with a passport, then converted the result into a small object and stopped checking. Every door now trusts the object rather than the person.

Notice the trade. Checking a passport at every door would be unbearable, so instead the hotel accepts a specific and very large risk: if the card leaves your pocket, whoever picks it up is, for all practical purposes, the guest in room 412. The doors have no way to know otherwise. They were never given a way.

Where the word “bearer” comes from#

There is an old word for this arrangement, and software borrowed it from banking rather than inventing it.

A cheque made out to a named person can only be paid to that person. A cheque made out to “bearer” can be paid to whoever walks in holding it. Bearer bonds worked the same way: the certificate itself was the ownership. Lose it and you have lost the money, because there is no register anywhere with your name in it, and the bond cannot tell the owner from the finder.

That is exactly the property the hotel card has, and exactly the property the tokens in this chapter have. The specification that defines how these tokens travel on the web says it in one sentence, and the sentence is unusually honest for a technical document. It describes a bearer token as a security token with the property that any party in possession of it can use it in any way that any other party in possession of it can.

Read that again. It does not say the holder is probably the right person. It says that possession and entitlement are the same thing. Everything else in this chapter is an attempt to live with that sentence.

The two kinds of key card#

Hotel key cards come in two designs, and the difference between them turns out to be the same difference that divides software tokens into two families.

In the first design, the card carries a number and nothing else. Card number 8814. The door lock has no idea what that means. When you press the card to the lock, the lock asks the hotel’s computer over a wire: is 8814 allowed in this room right now? The computer looks it up and answers yes or no. The card is meaningless by itself. All the meaning lives at the front desk.

In the second design, the card carries the answer. Written onto the card, in a form the lock can read, is: room 412, valid until eleven o’clock on the nineteenth of August, pool access yes, wine cellar no. The lock reads the card, checks the clock, and decides on its own. It never asks anybody. There is no wire.

Both are common in real hotels. The first is an online lock, the second an offline lock, and the trade between them is the trade you will meet over and over.

The online lock is expensive, because every door needs a wire and the front desk has to answer thousands of questions a day, but it is completely current: if a card is reported lost, the desk changes one record and every door knows immediately.

The offline lock is cheap and fast, with no wire, no waiting and doors that keep working when the network fails, but it is out of date the instant the card is printed. If a card is reported lost at nine in the morning, the lock on room 412 has no way of hearing about it. The card says “valid until the nineteenth”, and the lock believes the card, because believing the card is the only thing it knows how to do.

Everything printed on the card is a limit#

Look again at what the offline card carries: room 412, valid until eleven o’clock, pool yes, cellar no. Each of those is a restriction, and each exists because of something that would otherwise go wrong.

The room number stops the card being used on the wrong door. Without it a card is a master key, and a lost card opens the whole hotel. This is the most important line on the card and the one most often left blank in software.

The expiry time stops the card working forever. Without it, the only way to end access is to collect the card back, and people do not give cards back.

The list of permissions, pool yes and cellar no, stops the card doing more than the guest paid for. It also limits the damage from a theft: a stolen card that opens one room and a swimming pool is a much smaller problem than a stolen card that opens the safe.

A fourth item is invisible on most cards and essential: which hotel issued it. If two hotels in a chain share a card format and one has a dishonest clerk, a card printed at the bad hotel opens doors at the good one. In software this happens constantly, because the same card format is used by tens of thousands of organizations.

The card that fetches you a new card#

Hotels for long stays sometimes do something clever. Instead of one card valid for a month, they give you a card valid for one day plus a small paper slip, and each morning you present the slip at the desk and get a fresh card and a fresh slip. The thing that travels around the building all day, used at every door, dropped in corridors and left on tables, is only good until this evening. The thing that carries the real value, the right to keep getting cards, stays in your wallet and touches only the front desk.

There is a trick that goes with this, and it is the single best idea in this chapter. The desk keeps a note of which slip is current, and cancels yesterday’s when it issues today’s. Now suppose a thief photographs your slip. Two slips with the same number exist, and sooner or later both are presented. The moment the clerk sees a slip that was already cancelled, the right response is not to refuse that one slip but to cancel the entire stay and make the guest come to the desk in person. The clerk cannot tell which presentation was the guest and which the thief, so the only safe reading is that a copy exists somewhere it should not. That costs the honest guest a walk to reception. It costs the thief everything.

A card that only works in your hand#

There is one more design, rarer and more expensive, which removes the whole problem in exchange for a good deal of complexity. Imagine the door has a small pad on it. The card alone does nothing. To open the door you press the card to the lock and put your thumb on the pad at the same time. The card is now useless on its own. Finding it in the corridor gets you nothing at all, because the door is not checking the card, it is checking that the card and the thumb arrive together.

Software can do this, and it is the most effective single defence in this chapter, but the thumb is not a thumb. It is a secret key held by the program that was given the token, and the door asks that program for a small calculation only the holder of that key can perform. A thief who copies the token out of a log file holds something inert. This is not universal because it burdens every program that uses a token: hold a key, protect it, do arithmetic on every request. Bearer tokens need none of that, which is why they won, and why the industry has spent fifteen years trying to add the thumb pad back on.

A worked example: three nights at the Grand Northbank#

Meera checks into the Grand Northbank hotel at 14:12 on 16 August 2026 for three nights. The clerk checks her passport, takes payment, and encodes a card that says: room 412, valid from 16 August 14:12 until 19 August 11:00, lift yes, pool yes, cellar no, issued by the Grand Northbank Bengaluru. That is 68 hours and 48 minutes of validity.

On the second evening she leaves it on a restaurant table for four minutes. In those four minutes anyone could have taken it, and if they had, they would have had 40 hours of access to her room, with the hotel noticing nothing, because the lock on room 412 does not know how many cards exist.

Now change one thing. Suppose the hotel issued cards valid for eight hours, with a renewal slip. The card left on the table would have been good for at most eight hours and probably far less. That is the whole argument for short-lived tokens: it does not stop the theft, it shortens the theft.

Now change one more thing. Suppose the card required her thumb. The card on the table would have been good for nothing at all. That is the whole argument for proof of possession. Hold those two sentences; the rest of this chapter is detail.

Where the plain version stops being true#

A stolen card is missing; a stolen token is not#

The hotel card analogy has one flaw so large that it undermines everything comfortable about it, and you must carry the flaw or you will misjudge every risk here. Physical objects can only be in one place. If a thief takes Meera’s card, she notices, goes to reception, and the theft ends. The loss is self-announcing. A token is data, and copying it leaves the original exactly where it was. A token stolen from a log file, a crash report, a browser’s storage or a compromised laptop is still sitting in its rightful place, working perfectly, arousing no suspicion at all. Nobody notices, because from the owner’s side nothing has happened.

The honest version: every intuition you have about the security of physical keys is wrong for tokens, and it is wrong in the worst direction. Theft of a token is silent, unbounded in the number of copies, and detectable only by inference. This is why the reuse-detection trick with the renewal slip matters so much: it is one of the very few mechanisms in the whole of identity engineering that turns a silent theft into a loud one.

The card does not say who you are#

The plain version said the hotel converted an identity check into a card. That is how people think about tokens, and it is not what the specifications say.

The main kind of token in this chapter is an access token, described in the specification as a credential used to access protected resources, representing an authorization issued to the client. It records a decision about what may be done, not an assertion about who somebody is. There is a separate object for that, an identity token, which is chapter 41’s material.

This distinction causes real breaches. A service that receives an access token and concludes “this is Meera, therefore log her in as Meera” has made an assumption the token never claimed to support: it may have been issued to a different application, for a different purpose, and merely happen to contain Meera’s identifier.

The honest version: an access token answers “may the holder do this”, not “who is the holder”. Any system that reads identity out of an access token must check the audience and issuer fields first, and even then it is reading a side effect rather than the point.

The card the lock can read, everyone can read#

The offline card, the one with the answer written on it, has a property the analogy hides. In a hotel the writing on the stripe is at least awkward to read. In software the equivalent writing is almost always plain. The dominant structured token format encodes its contents in a reversible text encoding, not in a cipher. Anybody who obtains the token can read every field in it in under a second with a standard tool. The signature on it stops the contents being changed. It does nothing to stop them being read.

This surprises people who put an email address, a customer number or a national identifier into a token and then send it through a browser. The mechanics of that format, and the encryption option that does hide contents, belong to chapter 39.

The honest version: signing proves a token was not altered, and hides nothing at all. Treat every field you put in a structured token as public to anyone who ever handles the token.

Short-lived is shorter than you think, and longer than it looks#

The plain version implied that a short lifetime meaningfully limits an attacker. It does, but the arithmetic is less flattering than people expect.

A ten-minute token stolen at the moment of issue gives the attacker ten minutes, and ten minutes is a very long time for an automated program: a script can enumerate an account, download a transaction history, change a delivery address and add a payee in under ten seconds. Short lifetimes protect against tokens found later in old logs, not against tokens intercepted live.

There is a second problem in the other direction. A cached validation result keeps a revoked token working for as long as the cache lives. Clock drift makes services accept tokens past their stated expiry by whatever tolerance the implementation allows, commonly sixty seconds and sometimes far more. And a token used to start something long-running, a data export or a batch job, usually keeps that work alive after it expires, because the check was done at the start.

The honest version: the lifetime bounds when a token may be presented, not how long its effects last, and the true window is the stated lifetime plus the cache time plus the clock tolerance plus the duration of whatever it started.

Cancelling a card the lock has never heard of#

The plain version said the offline lock cannot hear about a cancelled card. That is not a limitation of hotels; it is the defining problem of structured tokens, and it has no clean solution. If a service validates a token entirely on its own, using a signature and the fields inside, there is by construction no moment at which it consults anybody, so revoking such a token means introducing exactly the consultation the design was meant to avoid. Every proposed answer is a compromise between the two.

The honest version: you cannot revoke a self-validating token without adding state somewhere. Anyone who says otherwise is either shortening the lifetime until revocation stops mattering, or has not thought about it. The technical half sizes that compromise precisely so you can decide whether to accept it.

The card that fetches cards is the real prize#

The renewal slip looks like a minor convenience. It is the most valuable credential in the system. An access token is worth minutes; a refresh token is worth weeks or months and can be exchanged for an unlimited number of access tokens in that time, which is why attackers who reach a token store take the refresh tokens.

The honest version: the security of the short-lived token is largely irrelevant if the long-lived one is stored carelessly beside it. Protect the refresh token as you would protect a password, because functionally that is what it is.

Proof of possession is proof about a key, not about a person#

The thumb pad was a comforting image and slightly dishonest. What the door checks in software is not a person and not a device. It is a key.

If that key sits in ordinary memory in an ordinary program on an ordinary laptop, malware can take the key along with the token and proof of possession has bought nothing. It helps only to the extent that the key is harder to remove than the token: keys in hardware, keys marked non-extractable by the browser, or keys in a process the attacker did not reach.

The honest version: binding a token to a key raises the cost of theft from “copy some text” to “extract a key”, and the size of that improvement depends entirely on where the key lives. Against a network attacker or a leaked log it is close to total. Against malware with full control of the machine it may be nothing.

The technical version#

What “bearer” means in the specification, exactly#

The governing document is RFC 6750, “The OAuth 2.0 Authorization Framework: Bearer Token Usage”, by Michael Jones and Dick Hardt, published in October 2012. It is one of the shortest security specifications in wide use, and its definition in section 1.2 is the sentence the whole chapter rests on: a bearer token is a security token with the property that any party in possession of the token, a “bearer”, can use the token in any way that any other party in possession of it can.

The companion document, RFC 6749, “The OAuth 2.0 Authorization Framework”, also October 2012, defines the objects. Section 1.4 defines an access token as a credential used to access protected resources, a string representing an authorization issued to the client. Section 1.5 defines a refresh token as a credential used to obtain access tokens when the current one becomes invalid or expires.

Two things follow, and they are not opinions. Transport security is load-bearing rather than optional: RFC 6750 section 5.3 requires TLS on every request carrying a bearer token, because a bearer token observed in transit is a bearer token owned, and a plain bearer presentation contains no signature, nonce or timestamp to make replay fail. And every other control in this chapter is a mitigation rather than a fix: short lifetimes, audience restriction, scope, deny-lists and rotation all reduce the value or the window of a stolen token, while only proof of possession changes the definition, by making the token no longer a bearer token.

The industry chose this deliberately and argued about it loudly. OAuth 1.0, published as RFC 5849 in April 2010 with Eran Hammer-Lahav as editor, required every request to be signed, using HMAC-SHA1, RSA-SHA1 or the deliberately misnamed PLAINTEXT method, all in section 3.4. OAuth 2.0 removed signing and relied on TLS instead. On 26 July 2012, three months before RFC 6750 appeared, Hammer resigned as editor and published an essay titled “OAuth 2.0 and the Road to Hell”, writing that 2.0 got rid of all signatures and cryptography at the protocol level and relied solely on TLS, which he argued made 2.0 tokens inherently less secure as specified. Fourteen years later the working group’s own best practice document tells implementers to bind tokens to keys. Both sides were partly right: bearer tokens are why OAuth 2.0 was adopted everywhere, and why the security best practice document is as long as it is.

The idea is older than the web. Needham and Schroeder’s paper “Using encryption for authentication in large networks of computers”, in Communications of the ACM in December 1978, established the trusted third party model. RFC 4120, “The Kerberos Network Authentication Service (V5)” by Neuman, Yu, Hartman and Raeburn, July 2005, obsoleting RFC 1510 from 1993, states that the Kerberos model is based in part on that protocol. Kerberos tickets are notably not bearer tokens: a ticket travels with an authenticator encrypted under a session key, which is the same idea as the thumb pad.

The three places a bearer token may ride, and the one that is now forbidden#

RFC 6750 section 2 defines exactly three ways to present a bearer token, and the modern position on each differs from the position in 2012. Section 2.1 defines the authorization request header field, and this is the only one you should use. Its grammar is small enough to quote in full:

b64token    = 1*( ALPHA / DIGIT /
                  "-" / "." / "_" / "~" / "+" / "/" ) *"="
credentials = "Bearer" 1*SP b64token

That grammar permits letters, digits, and the characters hyphen, full stop, underscore, tilde, plus and forward slash, with optional equals padding; it forbids a space, a comma and a quotation mark. That is a real constraint on token design, and people discover it late. A complete request against Northbank’s account service looks like this:

GET /v3/accounts/88214/balance HTTP/1.1
Host: api.northbank.example
Authorization: Bearer 2YotnFZFEjr1zCsicMWpAA
Accept: application/json

Section 2.2 defines the form-encoded body parameter, named access_token, permitted only when the content type is application/x-www-form-urlencoded and the method has a body. It exists for clients that cannot set headers, a real category in 2012 and almost none now.

Section 2.3 defines the URI query parameter, also named access_token, and this is the one to strike out. RFC 6750 section 5.3 already advised that bearer tokens should not be passed in page URLs, for example as query string parameters. RFC 9700, “Best Current Practice for OAuth 2.0 Security”, published in January 2025 as BCP 240 by Torsten Lodderstedt, John Bradley, Andrey Labunets and Daniel Fett, goes further: its section 4.3 covers credential leakage via browser history, and it states that clients must not pass access tokens in a URI query parameter.

A URL is the most porous object in computing: it appears in browser history, server access logs, every intermediate proxy log, the Referer header of every subsequent outbound request from that page, bookmarks, shared links, analytics payloads and error reports. Section 4.2 of RFC 9700 notes that a response carrying Referrer-Policy: no-referrer completely suppresses the Referer header in all requests originating from the resulting document.

When a token is rejected, the resource server replies with a WWW-Authenticate header, and section 3.1 of RFC 6750 defines exactly three error codes for it: invalid_request for a malformed request, one missing or repeating a parameter, or one using more than one method to present the token; invalid_token for a token that is expired, revoked, malformed or otherwise invalid; and insufficient_scope for a valid token that does not carry enough privilege. Those three are the entire vocabulary a resource server has for saying no, and they are what let a client decide whether to refresh, to re-authorize, or to give up.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="northbank",
 error="invalid_token",
 error_description="The access token expired"

Opaque or structured: introspection against self-validation#

The hotel’s online and offline locks map onto the two token families, and the choice between them is the largest architectural decision in this chapter.

An opaque token is a random string with no internal meaning; all of the meaning is held in a database at the authorization server. A resource server that receives one can ask about it or refuse it. The asking is standardized in RFC 7662, “OAuth 2.0 Token Introspection”, edited by Justin Richer, October 2015: the resource server posts the token to an introspection endpoint and receives a JSON object. The one required member is active, a boolean, which section 2 describes as a token that has been issued by this authorization server, is not expired, has not been revoked, and is valid for use at the protected resource. Optional members include scope, client_id, username, exp, iat, sub, aud, iss and jti.

POST /introspect HTTP/1.1
Host: id.northbank.example
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW

token=2YotnFZFEjr1zCsicMWpAA
&token_type_hint=access_token
{
  "active": true,
  "scope": "accounts:read transactions:read",
  "client_id": "ledgerly-web",
  "sub": "88214",
  "aud": "api.northbank.example",
  "iss": "id.northbank.example",
  "jti": "7c4f1a90e2b34d56",
  "exp": 1786958643,
  "iat": 1786958043
}

Opaque tokens can be made very good. GitHub’s redesigned formats, described in a blog post of 5 April 2021, give personal access tokens the prefix ghp_, OAuth access tokens gho_, user-to-server tokens ghu_, server-to-server tokens ghs_ and refresh tokens ghr_. The random portion carries 178 bits of entropy, up from 160, and the last six characters are a CRC32 checksum in Base62, so that a scanner can recognize a token anywhere it appears with an extremely low false-positive rate. That is an opaque token designed by people who expected it to leak.

A structured token carries its own claims and a signature. The dominant profile is RFC 9068, “JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens”, by Vittorio Bertocci, October 2021, which sets the typ header value to at+jwt and in section 2.2 requires seven claims: iss, exp, aud, sub, client_id, iat and jti. A resource server validates the signature against the issuer’s published keys, checks the claims, and answers without a network call. The format itself is chapter 39’s material.

Property Opaque Structured
Validated by Asking the issuer Verifying a signature
Network cost One call per check None after key fetch
Revocation Immediate Needs a deny-list
A leaked copy shows Nothing Every claim
Typical size 20 to 50 bytes 500 to 2000 bytes
Issuer availability Required Not required
Added latency Milliseconds Microseconds

The arithmetic matters. Northbank’s account service handles 8,000 requests a second at peak, so introspecting every one means 8,000 calls a second to the authorization server and an account service that stops working the moment the authorization server does. Caching the result for 60 seconds cuts that to the number of distinct tokens seen in 60 seconds, and lets a revoked token keep working for up to 60 seconds. That is the whole trade, expressed in one cache setting.

Most large deployments run a middle position: structured tokens internally between known parties with tiny lifetimes, opaque tokens for external clients, and the authorization server translating at the edge. The external world never sees a signed claim set, so nothing leaks and revocation is immediate; the internal world makes no introspection call, so nothing is slow. This is a convention, not a standard.

The fields, and the precise attack each one closes#

Every field in a token is there because of something that goes wrong without it. The table is the short form and the paragraphs after it are the reasons.

Field What its absence allows Defined in
iss Token from a rogue issuer RFC 9068 section 2.2
aud Replay at a second service RFC 8707 section 2
scope Access beyond consent RFC 6749 section 3.3
exp Use forever RFC 7519 section 4.1.4
jti Silent replay, no undo RFC 9068 section 2.2
sub Wrong account acted on RFC 9068 section 2.2
client_id Untraceable misuse RFC 9068 section 2.2
cnf Use by a non-holder RFC 7800 section 3.1

The issuer, iss, names the authorization server that minted the token. Without it, a resource server that trusts more than one issuer cannot tell whose signature it is checking, and an attacker who obtains a token from any trusted issuer can present it anywhere. RFC 9068 section 4 is unambiguous: the issuer identifier, typically obtained during discovery, must exactly match the value of the iss claim. Exact string comparison, not a prefix match, not a regular expression.

The audience, aud, names the service the token is for, and its absence is the most consequential omission in practice. Suppose Meera authorizes an application to read her account, and that application also talks to a small analytics service she has never heard of. With no audience, the analytics service can take the token it was given and present it to Northbank’s account API, which will accept it. RFC 8707, “Resource Indicators for OAuth 2.0”, by Brian Campbell, John Bradley and Hannes Tschofenig, February 2020, adds a resource request parameter whose value must be an absolute URI, and its section 3 puts the benefit plainly: an audience-restricted access token that is legitimately presented to a resource cannot then be taken by that resource and presented elsewhere for illegitimate access. RFC 9700 section 4.9 names the two variants, access token phishing by a counterfeit resource server and the compromised resource server, and section 4.10.2 gives the remedy: restrict tokens to a particular resource server and have that server verify the intended audience.

The scope limits what may be done. RFC 9700 section 2.3 states that the privileges associated with an access token should be restricted to the minimum required for the particular application or use case, which prevents clients from exceeding the privileges authorized by the resource owner, and that tokens should be audience-restricted to one resource server or, failing that, to a small set.

The expiry, exp, ends the token. The token identifier, jti, gives it a name, which is the only thing that makes replay detection and deny-listing possible at all; a token without one cannot be individually revoked even in principle. The subject, sub, names the account, and the client identifier, client_id, names the application, which is what lets an incident responder answer “which of our 300 integrations leaked this”.

The confirmation claim, cnf, is the field that stops a token being a bearer token, and it has its own subsection below. It comes from RFC 7800, “Proof-of-Possession Key Semantics for JSON Web Tokens (JWTs)”, by Michael Jones, John Bradley and Hannes Tschofenig, April 2016, whose section 3.1 states that by including a cnf claim the issuer declares that the presenter possesses a particular key.

Lifetime, refresh, and rotation with reuse detection#

RFC 6750 section 5.3 says that using short-lived tokens of one hour or less reduces the impact of them being leaked. That figure has held up as a ceiling rather than a target. Here are real values as of August 2026, all from vendor documentation rather than folklore.

System Credential Lifetime
AWS STS AssumeRole Session credentials 1 hour, up to 12
AWS STS GetSessionToken IAM user session 12 hours, up to 36
GitHub App installation Installation token 1 hour
GitHub App user token User access token 8 hours by default
Active Directory Kerberos user ticket 10 hours
Active Directory Kerberos service ticket 600 minutes
Active Directory Ticket renewal limit 7 days
RFC 6750 section 5.3 Bearer access token One hour or less

Every one of these systems pairs a short-lived credential with a longer-lived way of getting another one: AWS pairs session credentials with a role that may be assumed, Kerberos pairs service tickets with a ticket-granting ticket, OAuth pairs access tokens with refresh tokens. The pattern is universal because the alternative, re-authenticating every hour, is not tolerated by users.

That makes the refresh token the crown jewel, and RFC 9700 section 4.14.2 is the paragraph to know by heart. It offers two options and requires one of them for public clients, meaning clients that cannot keep a secret, such as anything in a browser or on a phone. The first is sender-constrained refresh tokens, where the authorization server cryptographically binds the refresh token to a certain client instance using RFC 8705 or RFC 9449. The second is refresh token rotation, where the authorization server issues a new refresh token with every access token refresh response and the previous refresh token is invalidated. Servers must use one of these methods to detect refresh token replay by malicious actors for public clients.

Rotation on its own is half of it. The valuable half is what you do when an invalidated refresh token comes back: since a rotated token can only be presented twice if a copy exists, the second presentation is direct evidence of compromise, and the correct response is to invalidate the entire chain descended from that grant rather than merely rejecting the request.

def refresh(presented_token):
    rec = store.lookup(presented_token)
    if rec is None:
        return error("invalid_grant")

    if rec.state == "rotated":
        # A copy exists. We cannot tell which caller
        # is the thief, so we trust neither.
        store.revoke_family(rec.family_id)
        alert("refresh reuse", rec.family_id, rec.sub)
        return error("invalid_grant")

    if rec.state == "revoked" or rec.expired():
        return error("invalid_grant")

    new_rt = random_token(256)
    store.mark_rotated(rec)
    store.insert(new_rt, family_id=rec.family_id,
                 sub=rec.sub, parent=rec.id)
    at = mint_access_token(rec.sub, rec.scope,
                           aud=rec.resource, ttl=600)
    return {"access_token": at,
            "refresh_token": new_rt,
            "expires_in": 600,
            "token_type": "Bearer"}

Three details decide whether that code works in production. Every refresh token descended from one grant shares a family_id, so revoking the family kills every branch. The reuse branch revokes before it alerts, because an alert that fires after the attacker has refreshed again is decoration. And a legitimate client can trigger the alarm by accident when a network timeout makes it retry a refresh whose response it never saw, so real deployments add a grace window of a few seconds during which the immediately previous token is accepted quietly. That grace window is a convention, not a standard; no specification defines it.

Northbank’s actual settings, for the worked example: access tokens live 600 seconds, refresh tokens live 30 days with a sliding renewal, rotation is on, reuse detection revokes the family, and the grace window is 10 seconds.

Binding the token to a key: mutual TLS and DPoP#

This is the thumb pad. There are two standardized ways to build it, both putting a cnf claim in the token that names a key and requiring the presenter to demonstrate control of that key. They differ in which layer does the demonstrating. The first is RFC 8705, “OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens”, by Brian Campbell, John Bradley, Nat Sakimura and Torsten Lodderstedt, published in February 2020. It does the work in the transport layer. The client presents an X.509 certificate during the TLS handshake. The authorization server takes the SHA-256 hash of the DER encoding of that certificate, encodes it with base64url, omits all trailing padding characters, and puts it in the token as cnf member x5t#S256, described in section 3.1. When the client later calls a resource server over mutual TLS, the resource server hashes the certificate it actually saw in the handshake and compares. If they differ, the token is refused.

{
  "iss": "id.northbank.example",
  "aud": "api.northbank.example",
  "sub": "88214",
  "client_id": "ledgerly-web",
  "exp": 1786958643,
  "cnf": {
    "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"
  }
}

The same document defines two client authentication methods: tls_client_auth in section 2.1, the PKI method, where the client is matched against a registered subject distinguished name or subject alternative name, and self_signed_tls_client_auth in section 2.2, where the client registers its own certificates through jwks or jwks_uri. Section 3.2 conveys the certificate hash in an introspection response using the same cnf and x5t#S256 structure, so opaque tokens can be certificate-bound too, and section 5 defines mtls_endpoint_aliases, because many deployments cannot ask for a client certificate on the host that serves ordinary traffic.

Mutual TLS is excellent and heavy. It requires certificate issuance and rotation, it interacts badly with load balancers and reverse proxies that terminate TLS, and it is impossible in a browser page.

The second mechanism is RFC 9449, “OAuth 2.0 Demonstrating Proof of Possession (DPoP)”, by Daniel Fett, Brian Campbell, John Bradley, Torsten Lodderstedt, Michael Jones and David Waite, September 2023. It works in the application layer, so it runs anywhere, including in a browser. The client generates a key pair and, on every request, creates a small JWT called a DPoP proof, signs it with the private key, and sends it in a DPoP header alongside the token. Section 4.2 specifies the contents: a JOSE header carrying typ with the value dpop+jwt, an alg naming an asymmetric signature algorithm that may be neither none nor symmetric, and jwk carrying the public key; and a payload carrying jti for this proof, htm for the HTTP method, htu for the target URI without query and fragment, iat for the creation time, ath for the base64url-encoded SHA-256 hash of the access token when one accompanies it, and nonce when a server has demanded one.

Section 6.1 defines the binding: the token carries cnf with member jkt, the base64url encoding of the JWK SHA-256 thumbprint of the client’s public key. Section 7.1 defines the DPoP HTTP authentication scheme, which replaces Bearer in the Authorization header, and this is the visible sign that a deployment has stopped using bearer tokens.

GET /v3/accounts/88214/balance HTTP/1.1
Host: api.northbank.example
Authorization: DPoP eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1
 NiIsImtpZCI6Im5iLTIwMjYtMDgifQ.eyJpc3MiOiJodHRwczovL2lk
 Lm5vcnRoYmFuay5leGFtcGxlIn0.pQrs
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIj
 p7Imt0eSI6IkVDIn19.eyJqdGkiOiJlMWozVl9iS2ljOC1MQUVCIiwi
 aHRtIjoiR0VUIn0.Ab3F

The nonce mechanism in sections 8 and 9 exists because iat alone is weak: a proof captured by a compromised resource server could be replayed elsewhere within the clock tolerance. A server wanting stronger replay protection returns a DPoP-Nonce header with the error use_dpop_nonce, and the client retries with the supplied nonce. The other error, invalid_dpop_proof, means the proof failed the validation rules of section 4.3. Section 10 defines dpop_jkt, which binds an authorization code to the DPoP public key before any token exists.

Here is the shape of it, with the check each party performs:

  Ledgerly                    id.northbank    api.northbank
     |                             |                |
  1. make key pair (P-256)         |                |
     |                             |                |
  2. POST /token + DPoP proof ---->|                |
     |                             | thumbprint the |
     |                             | jwk -> jkt     |
  3. <---- token with cnf.jkt -----|                |
     |                             |                |
  4. GET /balance                  |                |
     Authorization: DPoP <token>   |                |
     DPoP: <proof: htm,htu,ath> -------------------->|
     |                             |                | verify sig
     |                             |                | jwk -> jkt
     |                             |                | == cnf.jkt
     |                             |                | ath == H(t)
  5. <------------------------------- 200 balance --|

A thief who copies the token out of a log has the value from step 3 and nothing else, and the check at step 4 fails because they cannot produce a proof signed by the key the token names. The order of the checks matters: verify the proof’s signature, recompute the thumbprint from the embedded public key, compare it to cnf.jkt in the token, then compare ath to the hash of the presented token. Skipping the third check is the classic implementation error, and it makes the mechanism ornamental, because any key would then do.

An earlier attempt failed instructively. Token Binding, specified in October 2018 in RFC 8471, with RFC 8472 for the TLS negotiation extension and RFC 8473 for use over HTTP, bound tokens to the TLS connection itself. On 1 August 2018, before those RFCs were even published, Nick Harper posted an intent to remove Token Binding from Chrome on the blink-dev list, citing adoption of under 0.01% of HTTPS requests. The removal was opposed by enterprise and standards practitioners, and DPoP exists as a direct consequence: it puts the binding in the application layer, where no browser vendor’s decision can remove it.

Regulators have begun to require binding. The FAPI 2.0 Security Profile, a Final Specification of the OpenID Foundation dated 22 February 2025 and used for open banking, states in section 5.3.2.1 that the authorization server shall only issue sender-constrained access tokens, using either RFC 8705 or RFC 9449. NIST arrives by another road: SP 800-63C-4, final on 31 July 2025, gives the presentation method for FAL1 and FAL2 as a bearer assertion and for FAL3 as a holder-of-key assertion or a bound authenticator, requiring at FAL3 that the relying party verify the subscriber is in control of an authenticator in addition to the assertion. The highest assurance level in United States federal guidance is defined by the absence of bearer semantics.

Where tokens actually leak, with the incidents#

Tokens are rarely stolen by clever cryptography. They are copied out of ordinary places where nobody thought to look.

Route A documented case The control
URL query string RFC 9700 section 4.3 Header only
Referer header RFC 9700 section 4.2 no-referrer policy
Server and proxy logs Common, rarely public Redact Authorization
Browser storage Any XSS reads it Worker or BFF pattern
HAR file, crash dump Okta, October 2023 Scrub before upload
Source repositories 28.65m secrets in 2025 Secret scanning
Endpoint malware CircleCI, December 2022 Hardware-held keys

The third route, logging, is the most banal and the most common: an access log that records the full request line captures a token from a query string, and a debug logger that dumps headers captures one from Authorization. Those logs then go somewhere far more people can read than can read production. Redact by header name at the point of logging, and test it, because the usual failure is a second logger without the rule.

The fourth, browser storage, has a whole best practice document behind it: draft-ietf-oauth-browser-based-apps, by Aaron Parecki, Philippe De Ryck and David Waite, at revision 27 dated 6 July 2026 and in the RFC Editor queue as an intended Best Current Practice as of August 2026. A token in localStorage is readable by any script on the page, so one cross-site scripting flaw anywhere on the origin is a complete compromise, and the answers are architectural: keep the token out of the page behind a backend for frontend, or hold a non-extractable key in a service worker.

The fifth, diagnostic files, gave the clearest public case study of the decade. Okta’s root cause analysis of 3 November 2023 describes unauthorized access to its support case management system, where engineers routinely ask customers to upload HTTP Archive files, complete recordings of a browser session including headers. Some contained session tokens which could in turn be used for session hijacking attacks. The access ran from 28 September to 17 October 2023, files belonging to 134 customers were taken, and sessions were hijacked at five. The cause was an employee signing into a personal Google account in the work Chrome profile, where a service account password had been saved.

The sixth is source control, and it is enormous. GitGuardian’s State of Secrets Sprawl 2026 reports 28.65 million new hardcoded secrets in public GitHub commits during 2025, a 34% rise year on year and its largest recorded jump, and finds 64% of the secrets leaked in 2022 still valid. The response has been detection rather than prevention, which is why the April 2021 token format redesign matters: prefixes and checksums make scanning cheap. GitHub documents that a valid OAuth token, GitHub App token or personal access token pushed to a public repository or gist is automatically revoked, as is any token unused for a year.

The seventh is the machine itself, and it defeats most of the controls above. CircleCI’s incident report of 12 January 2023 describes a laptop compromised by malware on 16 December 2022, where the malware was able to execute session cookie theft, enabling the attacker to impersonate the targeted employee. That employee had privileges to generate production access tokens as part of their regular duties, so the attacker reached production; access was seen from 19 December and customer environment variables, tokens and keys were exfiltrated on 22 December. Two-factor authentication was in place and was irrelevant, because the stolen credential had been issued after the second factor was satisfied.

Theft at this scale is not hypothetical. On 28 September 2018 Facebook disclosed that three interacting bugs in the “View As” feature and the video uploader had let attackers steal access tokens, first estimated at nearly 50 million accounts and corrected on 12 October 2018 to about 30 million actually stolen. In April 2022 GitHub reported an attacker abusing stolen OAuth user tokens issued to two integrators, Heroku and Travis CI, to download data from dozens of organizations including npm, discovering the access on 12 April and notifying customers between 18 and 27 April. In none of these cases was a password stolen, and in all of them the tokens were enough.

Revoking a token nobody stored: the deny-list compromise#

RFC 7009, “OAuth 2.0 Token Revocation”, edited by Torsten Lodderstedt with Stefanie Dronia and Marius Scurtescu, August 2013, gives the protocol: a client posts to a revocation endpoint with parameter token and optionally token_type_hint. Section 2.2 has the server return HTTP 200 both for a successful revocation and for a token it does not recognize, because invalid tokens do not cause an error response since the client cannot handle such an error in a reasonable way. Section 2.1 says revoking a refresh token should also invalidate all access tokens based on the same grant, while revoking an access token may revoke the refresh token as well.

That protocol works perfectly for opaque tokens, because revocation is a row update and the next introspection call returns active: false. For a self-validating structured token it does nothing at all, because nobody ever asks.

There are four honest answers, and only four.

The first is to make the lifetime short enough that revocation stops being a distinct requirement. A token that lives 60 seconds gives a worst case of 60 seconds of continued access, which is why internal service-to-service tokens in large systems often live a minute or less.

The second is to check a deny-list on every request. That reintroduces state, but a far smaller kind than introspection, because the list only holds tokens that were revoked and have not yet expired. The arithmetic is the point. Northbank’s access tokens live 600 seconds and the bank issues 40,000 a minute at peak. A jti is 16 random bytes stored as 22 base64url characters, plus an 8-byte expiry, so call it 40 bytes an entry. In the worst imaginable case every token issued in the last 600 seconds has been revoked: 400,000 entries, 16 megabytes. In reality the revoked fraction is tiny, so the working set is a few thousand entries and a few hundred kilobytes, checked in memory in well under a millisecond.

def accept(token):
    claims = verify_signature(token, jwks)
    now = clock.now()
    if claims["iss"] != EXPECTED_ISSUER:
        return reject("invalid_token")
    if MY_AUDIENCE not in as_list(claims["aud"]):
        return reject("invalid_token")
    if claims["exp"] <= now - SKEW:
        return reject("invalid_token")
    if denylist.contains(claims["jti"]):
        return reject("invalid_token")
    if REQUIRED_SCOPE not in claims["scope"].split():
        return reject("insufficient_scope")
    return claims

def revoke(jti, exp):
    # entry may be dropped once the token expires
    denylist.put(jti, ttl=exp - clock.now())

The last line is the one to stare at. Every deny-list entry has a natural death at the moment the token would have expired anyway, which is what keeps the list bounded, and it is why deny-lists work for short-lived access tokens and do not work for API keys that never expire.

The third answer is to revoke at a coarser grain: record a per-subject timestamp, “no token issued to subject 88214 before 09:31:12 is acceptable”, checked against the token’s iat. One row per user, one write to log a customer out of everything, and it is what most “sign out of all devices” buttons actually do. It cannot revoke one token while leaving another alive, which is sometimes exactly what you need.

The fourth answer is to accept the window and publish it. A deliberate statement that revocation takes effect within a stated number of seconds is defensible; an accidental, undocumented version of the same thing is an audit finding.

“Stateless tokens” is therefore a claim about the common path, not about the system: every deployment that promises immediate revocation has state somewhere, and the only question is how small and how fast it can be made.

Token exchange and the delegation chain#

Real systems are not two parties. A request arrives at a gateway, which calls an accounts service, which calls a payments service, which calls a ledger. The question at each hop is what token to use, and there are three bad answers before the good one. Passing the original token unchanged all the way down gives every service a credential valid at every other service, so a compromise anywhere is a compromise everywhere. Letting each service use its own service credential and forget the user leaves the ledger with no idea on whose behalf it is writing, and an audit trail that says only that the payments service did it. Inventing a header, X-User-Id: 88214, and trusting it means one compromised service can act as any user.

The good answer is standardized. RFC 8693, “OAuth 2.0 Token Exchange”, by Michael Jones, Anthony Nadalin, Brian Campbell as editor, John Bradley and Chuck Mortimore, January 2020, defines the grant type urn:ietf:params:oauth:grant-type:token-exchange: a service presents the token it holds and asks for a narrower one, addressed to the next service and recording who is acting for whom.

Required request parameters are grant_type, subject_token and subject_token_type; optional ones are resource, audience, scope, requested_token_type, actor_token and actor_token_type, the last being required whenever actor_token is present. The subject_token represents the identity of the party on behalf of whom the request is being made, and the actor_token the identity of the acting party. The response requires access_token, issued_token_type and token_type, recommends expires_in, and allows scope and refresh_token. Token types are named by URI, including urn:ietf:params:oauth:token-type:access_token, :refresh_token, :id_token and :jwt.

POST /token HTTP/1.1
Host: id.northbank.example
Content-Type: application/x-www-form-urlencoded

grant_type=
 urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&subject_token=eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiI4ODIxNCJ9.Zx
&subject_token_type=
 urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
&audience=api.northbank.example%2Fledger
&scope=ledger%3Awrite

Section 4.1 defines the act claim, a means within a JWT to express that delegation has occurred and to identify the acting party to whom authority has been delegated. It nests, so a chain three deep is readable in one object:

{
  "aud": "api.northbank.example/ledger",
  "iss": "id.northbank.example",
  "sub": "88214",
  "act": {
    "sub": "payments.northbank.internal",
    "act": { "sub": "accounts.northbank.internal" }
  }
}

Read it outward from the middle. The subject is still Meera, customer 88214, because her account is the one to be debited; the immediate actor is the payments service, and behind it the accounts service. The ledger can therefore answer three questions the bad designs cannot: whose money is this, who asked, and by what path.

Section 4.4 defines the counterpart, may_act, which states that one party is authorized to become the actor and act on behalf of another. It is the field that lets an authorization server refuse an exchange rather than issue it: a token with no may_act cannot be exchanged for a delegated one at all.

RFC 8693 distinguishes two modes, and the difference is not cosmetic. Impersonation produces a token in which the recipient cannot tell that anyone other than the subject is involved, because there is no act claim. Delegation produces a token with an act claim, so the chain is visible. Impersonation is what most systems do by accident; delegation is the only one of the two that lets an investigator answer “who actually pressed the button”.

  Meera            gateway        payments        ledger
    |                 |               |              |
 token A (aud=gateway, sub=88214, scope=pay)         |
    |---------------->|               |              |
    |          exchange A -> B         |             |
    |          aud=payments            |             |
    |          act={gateway}           |             |
    |                 |-------------->|              |
    |                 |        exchange B -> C       |
    |                 |        aud=ledger            |
    |                 |        act={payments,        |
    |                 |             act:{gateway}}   |
    |                 |               |------------->|
    |                 |               |   sub=88214  |
    |                 |               |   scope=     |
    |                 |               |   ledger:write

Each token in that chain is narrower than the one before it, and token C opens exactly one door: writing a ledger entry. If the ledger service is breached, the attacker holds a token that writes ledger entries and nothing else. That is why delegation chains belong in the same chapter as bearer semantics: since possession is entitlement, the only defence left is to make what is possessed worth as little as possible.

The token table: what each kind costs you when it is stolen#

This is the table to copy into your own design document, with your own rows added. It assumes theft has already happened, which is the assumption the whole chapter has argued for.

Token If stolen First mitigation
Bearer access token Acts as user until exp DPoP or mTLS binding
Refresh token New tokens for weeks Rotation, reuse detect
ID token Claims leak, replay Short exp, nonce, aud
Authorization code Traded for real tokens PKCE, one-time use
Session cookie Whole logged-in session HttpOnly, server logout
Long-lived API key Everything, forever Expiry, scope, rotation
Personal access token Repository read-write Expiry, secret scanning
Signed JWT with no jti Valid to exp, no undo Short exp, subject cutoff
Certificate-bound token Nothing without the key Key in HSM or TPM
DPoP-bound token Nothing without the key Non-extractable key
Introspected opaque token Until revoked centrally Revoke, no result cache
Service account key file Full service privilege Workload identity

Two rows deserve comment. The last rows point the same way: the way to win is to stop the credential being a bearer credential, either by binding it to a key or by replacing it with an identity the platform issues afresh each time. And the long-lived API key row is the one that will hurt you, because almost every organization has it and almost nobody inventories it.

Northbank end to end: the worked example in full#

Meera authorizes Ledgerly to read her Northbank accounts, which is the hotel story again with real values. She completes the authorization at Northbank’s identity service at 09:14:03 UTC on 17 August 2026. Ledgerly has already generated a P-256 key pair in the browser, marked non-extractable, and sent a DPoP proof with its token request. The response comes back like this.

{
  "access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiJ9...",
  "token_type": "DPoP",
  "expires_in": 600,
  "refresh_token": "8xLOxBtZp8",
  "scope": "accounts:read transactions:read"
}

Decoded, the claim set is this, where 1786958043 is 09:14:03 UTC on 17 August 2026 and 1786958643 is exactly 600 seconds later.

{
  "iss": "id.northbank.example",
  "aud": "api.northbank.example",
  "sub": "88214",
  "client_id": "ledgerly-web",
  "scope": "accounts:read transactions:read",
  "iat": 1786958043,
  "exp": 1786958643,
  "jti": "7c4f1a90e2b34d56",
  "cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }
}

Count the defences. The aud means a copy presented to Northbank’s card service is refused. The scope means a copy cannot move money, because payment endpoints require payments:write. The exp means a copy found in a log at ten past ten is already dead. The jti means this one token can be deny-listed without disturbing the other 39,999 issued that minute. The cnf means a copy is inert anyway, because the holder cannot sign a DPoP proof for the key whose thumbprint is 0ZcOCORZ.... And the iss means a token minted by a different bank running the same software is refused outright.

Now run the failure. At 09:19 an error-tracking integration on Ledgerly’s site captures an unhandled exception and the payload includes the outbound request headers, so the access token is now in a third-party system readable by every Ledgerly engineer and by that vendor. Without DPoP it is a live credential for another five minutes, which is more than enough to read Meera’s balance and her whole transaction history. With DPoP it is a string, because the thief cannot produce a proof over the right method and URI signed by a key they do not have, and the resource server’s thumbprint check against cnf.jkt fails.

Now run the worse failure. At 02:00 the following night, malware on a Ledgerly developer’s laptop reads the browser profile and takes the token and, because the developer had disabled the non-extractable flag while debugging, the private key too. Everything above stops helping. The attacker refreshes at 02:03 and receives a fresh access token and a rotated refresh token. Meera’s own browser refreshes at 08:40 the next morning, presenting the refresh token it still holds, which the server marked as rotated at 02:03.

That presentation is the alarm. The server cannot tell which caller is the thief, so it revokes the whole family, logs the event against subject 88214 and requires re-authentication. Meera sees a login prompt. The attacker’s chain is dead, and it was detected only because somebody chose rotation with reuse detection over a plain long-lived refresh token.

That six-and-a-half-hour window is the honest measure of what these controls achieve. They did not prevent the compromise; they bounded it, surfaced it, and handed an incident responder a customer number, a client identifier, a token identifier and a timestamp. In a bearer world, that is what winning looks like.

What experts still disagree about#

Three questions are genuinely open as of August 2026. Experts disagree on whether structured tokens should ever be handed to external clients: one camp values surviving an authorization server outage, the other objects that a signed claim set leaks information and makes revocation permanent debt. They disagree on whether DPoP earns its complexity outside regulated contexts, since a subtly wrong implementation gives the appearance of binding without the substance. And they disagree on how much can be protected inside a browser. All three positions are held by people who have run very large systems.

38.98 Common wrong ideas#

Wrong: A bearer token is safe because it is only ever sent over HTTPS. Right: TLS protects the token from an observer on the wire and from nothing else. The same token sits in browser storage, access logs, proxy logs, crash reports and error-tracking payloads, and in every one of those places it is a complete credential requiring no password, no second factor and no further check.

Wrong: A short access token lifetime makes a stolen token harmless. Right: It makes a stolen token time-limited, which is a different thing. An automated attacker needs seconds, not minutes, and the true window is the stated lifetime plus any introspection cache, plus the clock skew tolerance, plus the duration of any long-running work the token started before it expired.

Wrong: A self-validating token cannot be revoked. Right: It cannot be revoked without adding state, which is not the same thing. The four workable answers are a lifetime short enough that revocation stops mattering, a deny-list keyed on the token identifier and bounded by its lifetime, a per-subject cutoff timestamp checked against the issued-at claim, and a published statement of how long revocation takes.

Wrong: Signing a token protects what is written inside it. Right: A signature proves the contents were not altered and proves who issued them. It hides nothing. The contents of a structured token are readable by anyone who ever holds the token, so every claim in it should be treated as public unless the token is separately encrypted, which chapter 39 covers.

Wrong: If the token contains a user identifier, the service can log that user in. Right: An access token represents an authorization to do something, not an assertion about who somebody is. A service reading identity out of one must at least verify the issuer exactly and confirm it is itself in the audience, and even then it is reading a side effect. The object built for that question is the identity token, covered in chapter 41.

Wrong: Refresh token rotation prevents refresh token theft. Right: It detects it. Rotation does not stop a copy being made; it guarantees that when both copies are eventually used, the second use is provably wrong. The value comes entirely from what the server does next, which must be to revoke the whole family descended from that grant, not merely to reject the one request.

Wrong: A token in the query string is acceptable if the connection is encrypted. Right: Encryption hides the URL from the network, not from browser history, access logs, proxy logs, the Referer header of every outbound request from the resulting page, bookmarks, shared links or analytics. RFC 9700 states that clients must not pass access tokens in a URI query parameter.

Wrong: Binding a token to a key with DPoP or mutual TLS makes theft impossible. Right: It makes the token alone useless and moves the target to the key, so the improvement is exactly the difficulty of stealing that key: very large for one in a hardware module or marked non-extractable in a browser, close to zero for one in a file beside the token on a compromised machine.

Wrong: Opaque tokens are outdated and structured tokens have replaced them. Right: Both are current and solve different problems. Opaque tokens revoke instantly, leak nothing when copied and are 20 to 50 bytes; structured tokens validate without a network call and survive the issuer being unavailable. Most large deployments use opaque tokens at the external edge and structured tokens internally, a widespread convention rather than a requirement.

38.99 Chapter summary in 20 lines#

  1. RFC 6750 section 1.2 defines a bearer token as one that any party in possession of it can use exactly as any other party in possession of it can.
  2. That definition means possession and entitlement are the same thing, so a token in a log file is a working credential and not merely a clue.
  3. Everything here except proof of possession is a mitigation that reduces the value or the window of a stolen token rather than removing the risk.
  4. RFC 6750 section 2 allows a token in the Authorization header, in a form-encoded body, or in a URI query parameter, and modern practice permits only the first.
  5. RFC 9700, published in January 2025 as BCP 240, states that clients must not pass access tokens in a URI query parameter.
  6. An opaque token carries no meaning and is checked by asking the issuer, using the introspection protocol of RFC 7662, whose one required response member is active.
  7. A structured token carries its own claims and a signature and is checked locally, with RFC 9068 requiring iss, exp, aud, sub, client_id, iat and jti.
  8. The choice between them is a trade of immediate revocation and zero leakage against zero network cost and independence from the issuer’s availability.
  9. The iss claim must be compared by exact string match, because a resource server that trusts several issuers cannot otherwise tell whose signature it just verified.
  10. The aud claim is the field whose absence lets any service that receives a token replay it at any other service, and RFC 8707 defines the resource parameter that sets it.
  11. Scope limits what the token may do, and RFC 9700 section 2.3 requires privileges restricted to the minimum needed for the particular application or use case.
  12. RFC 6750 section 5.3 advises access tokens of one hour or less, and AWS session credentials and GitHub App installation tokens both default to exactly that.
  13. The refresh token is the credential that matters, because it is worth weeks of access, and RFC 9700 section 4.14.2 requires public clients to use either sender-constrained refresh tokens or rotation.
  14. Rotation issues a new refresh token on every use and invalidates the old one, so a second presentation of an invalidated token is proof that a copy exists.
  15. The correct response to that proof is to revoke the entire family of tokens descended from the original grant, because the server cannot tell which caller is the thief.
  16. Proof of possession removes bearer semantics: RFC 8705 binds a token to a TLS client certificate through cnf member x5t#S256, and RFC 9449 binds it to an application key through cnf member jkt.
  17. The FAPI 2.0 Security Profile, final on 22 February 2025, requires one of those two, and NIST SP 800-63C-4, final on 31 July 2025, defines its highest federation assurance level by the absence of bearer assertions.
  18. Tokens leak through URLs, referrers, logs, browser storage, diagnostic files, source repositories and compromised endpoints, and each of those routes has a documented public incident behind it.
  19. A self-validating token can still be revoked with a deny-list of token identifiers bounded by the lifetime, which at 40,000 tokens a minute and 600 seconds is at most 400,000 entries and about 16 megabytes.
  20. RFC 8693 token exchange lets each service obtain a narrower token for the next hop, with a nested act claim recording who is acting for whom, which is the only way to keep both least privilege and an audit trail.

Chapter sources: RFC 6750, “Bearer Token Usage”, Michael Jones and Dick Hardt, October 2012, sections 1.2, 2.1 to 2.3, 3.1 and 5.1 to 5.3; RFC 6749, edited by Dick Hardt, October 2012, sections 1.4, 1.5 and 3.3; RFC 5849, edited by Eran Hammer-Lahav, April 2010, section 3.4, with Hammer’s essay “OAuth 2.0 and the Road to Hell” of 26 July 2012; RFC 7009, edited by Torsten Lodderstedt with Stefanie Dronia and Marius Scurtescu, August 2013, sections 2.1 and 2.2; RFC 7662, edited by Justin Richer, October 2015, section 2; RFC 7800, Michael Jones, John Bradley and Hannes Tschofenig, April 2016, sections 3.1 to 3.5; RFC 8693, Michael Jones, Anthony Nadalin, Brian Campbell as editor, John Bradley and Chuck Mortimore, January 2020, sections 2.1, 2.2, 3, 4.1 and 4.4; RFC 8705, Brian Campbell, John Bradley, Nat Sakimura and Torsten Lodderstedt, February 2020, sections 2.1, 2.2, 3.1 to 3.3 and 5; RFC 8707, Campbell, Bradley and Tschofenig, February 2020, sections 2 and 3; RFC 9068, Vittorio Bertocci, October 2021, sections 2.1, 2.2 and 4; RFC 9449, Daniel Fett, Brian Campbell, John Bradley, Torsten Lodderstedt, Michael Jones and David Waite, September 2023, sections 4.2, 4.3, 6.1, 7.1, 8, 9 and 10; RFC 9700, Lodderstedt, Bradley, Andrey Labunets and Fett, January 2025, published as BCP 240, sections 2.1.2, 2.3, 2.4, 4.2, 4.3, 4.9, 4.10 and 4.14.2; RFC 8471, RFC 8472 and RFC 8473 on Token Binding, all October 2018, with the blink-dev “Intent to Remove: Token Binding” posted by Nick Harper on 1 August 2018; RFC 4120, Neuman, Yu, Hartman and Raeburn, July 2005, obsoleting RFC 1510 of 1993, and Roger Needham and Michael Schroeder, “Using encryption for authentication in large networks of computers”, Communications of the ACM volume 21 number 12, December 1978; draft-ietf-oauth-browser-based-apps-27, Aaron Parecki, Philippe De Ryck and David Waite, 6 July 2026, in the RFC Editor queue as an intended Best Current Practice at the time of writing; draft-ietf-oauth-v2-1-15 of 2 March 2026 for the status of OAuth 2.1; the OpenID Foundation FAPI 2.0 Security Profile, Final Specification of 22 February 2025, section 5.3.2.1; NIST Special Publication 800-63C-4, final on 31 July 2025, table 1 and the FAL3 requirements; Facebook’s “Security Update” of 28 September 2018 and “An Update on the Security Issue” of 12 October 2018 for the View As token theft and its revision from 50 million to 30 million; the GitHub blog post “Security alert: stolen OAuth user tokens” of 15 April 2022, updated 27 April 2022; the CircleCI incident report of 12 January 2023; Okta’s root cause analysis of 3 November 2023 for the HAR file exposure affecting 134 customers between 28 September and 17 October 2023; the GitHub blog post “Behind GitHub’s new authentication token formats” of 5 April 2021 for the prefixes, 178 bits of entropy and CRC32 checksum, with GitHub’s documentation on token expiration and revocation and on installation access tokens; GitGuardian, “The State of Secrets Sprawl 2026”, for the 28.65 million secrets found in public GitHub commits in 2025 and the 64% of 2022 secrets still valid; the AWS Security Token Service API reference for AssumeRole and GetSessionToken durations; and Microsoft’s Active Directory default domain policy for the 10-hour user ticket, 600-minute service ticket and 7-day renewal limits.