A bearer token is an access credential that the server accepts from anyone who sends it. RFC 6750, the 2012 spec for bearer token usage in OAuth 2.0, defines it as a token that any party in possession of it “can use the token in any way that any other party in possession of it can,” with no need “to prove possession of cryptographic key material.” The client sends it in an Authorization: Bearer header, the API checks the token, and nothing in the request shows who sent it.
That makes bearer token authentication easy to adopt, and it makes a copied token as good as the original. Most of what goes wrong with AI agents follows from it too: an agent that can read your .env file can read every bearer token in it.
How does bearer token authentication work?
Bearer token authentication involves three parties. An authorization server issues the token, a client keeps it and attaches it to each request, and the resource server, the API that holds the protected resource, checks it before answering. OAuth 2.0 (RFC 6749) calls the token an access token, “a string representing an access authorization issued to the client.” Bearer is how that string gets presented.
RFC 6750 was written for OAuth, but it says it defines “a general HTTP authorization method that can be used with bearer tokens from any source.” So the same header carries a Stripe secret key, a GitHub token or a JWT signed for a CI job. The spec also makes TLS mandatory, because the token is the entire credential: whoever reads it off the network has it.
How the API checks the token depends on what the token is. An opaque token is a random string that means nothing to the API, which asks the authorization server about it through token introspection (RFC 7662). A JWT carries signed claims that the API can verify on its own with the issuer’s public key. The spec leaves the choice to the implementer.
The Authorization: Bearer header
Here’s the request from Section 2.1 of RFC 6750:
GET /resource HTTP/1.1
Host: server.example.com
Authorization: Bearer mF_9.B5f-4.1JqM
And a generic curl equivalent, with your token in place of <token>:
curl -H "Authorization: Bearer <token>" https://server.example.com/resource
That bearer token header is all that API docs mean by bearer authentication: the scheme name, one space, then the token. The grammar allows letters, digits and - . _ ~ + /, with optional = padding at the end, so base64url strings and JWTs go in as they are.
RFC 6750 allows three ways to send the token: this header, an access_token field in a form-encoded POST body, or an access_token query parameter. Clients SHOULD use the header and resource servers MUST accept it. The query parameter came with a warning about “the high likelihood that the URL containing the access token will be logged”, so it SHOULD NOT be used. RFC 9700, the OAuth 2.0 Security Best Current Practice published in January 2025, updates RFC 6750 and makes that a hard rule: clients “MUST NOT pass access tokens in a URI query parameter.”
When a token is missing or wrong, the server answers with a WWW-Authenticate: Bearer header and one of three error codes: invalid_request (400), invalid_token (401) for a token that is “expired, revoked, malformed, or invalid for other reasons”, or insufficient_scope (403). An expired token gets this, per Section 3 of the spec:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="example",
error="invalid_token",
error_description="The access token expired"
A 401 tells the client to get a new token. A 403 with insufficient_scope means the token is valid and doesn’t cover this call, and asking for a fresh copy of the same token won’t change that.
Where do you get a bearer token?
You get a bearer token from whoever protects the API. With OAuth 2.0 that’s the authorization server’s token endpoint: the client signs in or exchanges its own credentials, and the response carries the token, a token_type of Bearer and a lifetime in seconds. Other APIs skip OAuth and issue a long-lived key from a dashboard, which they accept in the same header.
The token response in RFC 6750 looks like this:
{
"access_token":"mF_9.B5f-4.1JqM",
"token_type":"Bearer",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA"
}
expires_in of 3600 is one hour, the upper limit RFC 6750 recommends. The refresh_token lives longer and gets the client the next one, which makes it the more valuable thing to steal.
Stripe is the common non-OAuth case. It accepts its secret API key as a bearer token, so Authorization: Bearer <STRIPE_API_KEY> is an ordinary Stripe request (Keep Stripe API keys off laptops). Anthropic takes its key in an x-api-key header instead, as Drew Gregory’s post on proxying Claude Code shows. Either way the key is a static secret someone copied out of a dashboard, with no expiry, and that’s the case the rest of this guide worries about most.
Bearer token vs JWT
Bearer describes how a token is used. JWT describes what it’s made of. A JSON Web Token (RFC 7519) is a signed set of claims, and RFC 9068 profiles it for OAuth access tokens with the claims iss, exp, aud, sub, client_id, iat and jti. Send that JWT in the Authorization header with no proof of possession and it’s a bearer token. Bearer tokens that aren’t JWTs are opaque.
The two formats behave differently once a token leaks. An API verifies a JWT alone: it checks the signature against the authorization server’s public key, confirms that aud names this API, and checks that exp hasn’t passed. That’s fast, and it means a stolen JWT keeps working until exp, since the API never consults anything that could say otherwise. oauth.net’s guidance for JWTs is to keep lifetimes to “minutes, not hours.” An opaque token costs the API a round trip to the authorization server, and in return it can be revoked there.
Access token vs bearer token is a related mix-up. An access token is the OAuth role, the credential that grants access to an API. Bearer is the most common way to present one; oauth.net calls bearer tokens “the default access token type in OAuth 2.0.” An access token bound with DPoP is still an access token, and it’s no longer a bearer token.
Our own OIDC integrations sit on the receiving end of the JWT route. A CI job or cloud agent sends its identity provider’s JWT to Formal as a bearer token, and Formal verifies the signature, issuer, audience and time claims before mapping it to a machine user. The design reasons are in Aditya Saligrama’s post on cloud agents and OIDC.
Bearer token vs API key, Basic auth and OAuth
These four get compared as if they were alternatives, and they sit at different layers. Bearer and Basic are HTTP authentication schemes. An API key is a kind of secret. OAuth 2.0 is the framework that issues most bearer tokens.
| Bearer token | API key | Basic auth | OAuth 2.0 | |
|---|---|---|---|---|
| What it is | An HTTP scheme that carries a token | A secret a provider issues to an account | A username and password, Base64-encoded | A framework for issuing access tokens |
| Defined in | RFC 6750 (2012) | Each provider’s docs | RFC 7617 (2015) | RFC 6749 (2012) |
| Issued by | An authorization server, or any source | The API provider | The account owner | Defines the authorization server |
| Typical lifetime | One hour or less, per RFC 6750 | Until someone revokes it | Until the password changes | Set by the server in expires_in |
| Where it’s sent | Authorization: Bearer | The provider’s choice: Authorization: Bearer (Stripe) or a custom header (x-api-key) | Authorization: Basic | Usually as a bearer token |
| What a leak gives away | What the token’s scope and audience allow, until it expires | Everything the key can do, until it’s revoked | The account itself | Depends on the token type issued |
The leak row is where they differ most. An API key sent as a bearer token gets the header’s syntax and none of what RFC 6750 assumes about the token behind it: often no expiry, no scope and no audience. Basic auth resends the password on every request, and RFC 7617 says the scheme isn’t secure without something like TLS around it, since the username and password otherwise cross the network “as cleartext.” Bearer borrowed its header syntax from Basic, as RFC 6750 itself notes.
How do bearer tokens leak?
Bearer tokens leak wherever a request or a credential gets written down: URLs, logs, cookies, config files, command lines and now the context window of an AI agent. RFC 6750 names four threats: forging or modifying a token, disclosing it, redirecting it to a server it wasn’t meant for, and replaying it. Disclosure is the cheap one, because a disclosed bearer token needs no cryptography to use.
The 2012 list is about the web. A token in a page URL ends up in browser history and server logs, which RFC 6750 says “may not adequately secure URLs.” A token in a cookie sent over plain HTTP can be read on the network. When TLS ends at a load balancer, the hop from there to the backend has to keep the token protected as well.
On engineers’ laptops, most bearer tokens sit in files. The Stripe guide starts from a key that “sits on their laptop, in a .env file or shell profile.” Coding agents read those files. Drew’s post puts it this way: “Claude Code has access to all environment variables present in your terminal session […], and Claude Code has access to read the files from the directory where you run claude.” Gitignoring a .env file keeps it out of the repository and does nothing about a process that reads the disk. Once a token is in the agent’s context, the agent can print it, paste it into a tool call or send it to a host a prompt injection named.
Command lines are less obvious. An agent that clones with https://x-access-token:<token>@github.com/acme/app.git puts the token in the arguments of git and of the git-remote-https process it starts. Use $GITHUB_TOKEN in the URL instead and the agent’s tool call shows only the variable name, while the value the shell substitutes lands in the process arguments (Protect Git tokens in command lines). Anything that lists processes or records command lines now has it. That includes our own Endpoint: it stores the command line of an intercepted process as-is, and while Formal redacts the Authorization header Git sends, it doesn’t redact the command line. The same guide shows how to close that gap.
MCP OAuth tokens are the newest case. A remote MCP server that uses OAuth hands the client an access token and a refresh token, and the client keeps both on the device. Claude Code stores MCP OAuth credentials in the macOS Keychain, which beats a plaintext config file, and the stored value is still a working bearer token once something reads it.
Logs sit underneath all of these. A proxy, an API gateway or an LLM log that records full headers, URLs, command lines or prompts holds every token that passed through it.
Protecting bearer tokens
Most of the security section of RFC 6750 still holds. Use TLS on every request and validate the certificate chain. Keep tokens out of page URLs, and out of cookies that can travel in the clear. Issue short-lived tokens, “one hour or less,” and give each one an audience restriction so it only works at the API it was minted for. The audience is what defeats token redirect: a token issued for the billing API gets refused by the reporting API.
RFC 9700 adds what the years of OAuth deployments since 2012 taught. The privileges of an access token “SHOULD be restricted to the minimum required.” Query strings are out entirely. Refresh tokens held by public clients, which can’t keep a secret, must be sender-constrained or rotated on each use. And it asks for sender-constrained tokens in general, the one change aimed at theft itself.
Revocation is the other half. An opaque token can be revoked at the authorization server (RFC 7009). A JWT can’t be pulled back without adding state, so its short lifetime does the work. For CI jobs and agents, the strongest version of short-lived is a token minted per job from a signed identity, which CI/CD secrets management walks through. It’s zero standing privileges applied to API credentials.
Sender-constrained tokens: DPoP and mTLS
A sender-constrained token works only for the client it was issued to. RFC 9700 describes the sender as “obliged to demonstrate knowledge of a certain secret as a prerequisite for the acceptance of that token,” and says authorization and resource servers “SHOULD use mechanisms for sender-constraining access tokens.” Two standards do the binding:
| DPoP (RFC 9449, 2023) | mTLS (RFC 8705, 2020) | |
|---|---|---|
| Token bound to | A public key the client generates; the private key stays with the client | The client’s X.509 certificate |
| Sent with each request | Authorization: DPoP <token> plus a DPoP header holding a signed proof JWT | The certificate, in the TLS handshake |
| What the server checks | The proof’s signature, and that its key matches the key the token is bound to | That the certificate matches the fingerprint in the token |
| Fits | Browser and native apps with no certificate infrastructure | Services that already run a PKI |
RFC 9449 states its goal as preventing “unauthorized or illegitimate parties from using leaked or stolen access tokens.” It also changes the header. A DPoP-bound token isn’t sent with the Bearer scheme at all: it goes out as Authorization: DPoP, next to a fresh proof in the DPoP header, so an API that only parses Bearer needs changes before it can accept one. That, plus clients that can hold a private key, is the work DPoP asks for. In return, a token copied out of a log or a config file fails on the first request. oauth.net notes that FAPI 2.0 requires or recommends it.
Keeping bearer tokens out of AI agents’ reach
Every control above assumes the client can keep a secret. A coding agent that runs as the engineer, with the engineer’s files and environment, can’t make that promise, and an allowed host such as api.stripe.com accepts a stolen key as readily as the real one. For agents, we’d rather move the token than guard it.
“Going from 0.0.0.0/0 to an allowlist felt like progress, until we realized every allowed domain is still an exfiltration channel. Formal lets our production agent do the work safely.”
— Kevin Qiu, Lead Security Engineer @ Latent Health
Adding Authorization: Bearer at the proxy
With credential injection, the agent’s request leaves the laptop or sandbox with no key in it, and a proxy adds the key on the way out. In the Stripe guide, the Formal Endpoint intercepts requests to api.stripe.com and forwards them to a Formal Connector with the developer’s Formal identity. A Native User on the Connector sets Authorization: Bearer <key> on every Stripe request and replaces any Authorization header the client sent, so a placeholder on the laptop never reaches Stripe. The client keeps calling https://api.stripe.com.
Verify Full TLS makes the Connector check the certificate chain and hostname of api.stripe.com before it sends the key. A Rego policy then narrows what the injected key can do; the guide’s example lets the payments-engineers group read balances, charges and customers, and blocks everything else. The key is stored encrypted in Formal or set as an environment variable on the Connector, or a hook pulls it from a vault at connection time. The Formal Endpoint page covers injection for agents, and Drew’s post applies it to the Anthropic key.
When the token has to live on the laptop: sealed MCP OAuth tokens
Injection works when the proxy can hold the token. MCP OAuth doesn’t fit that: the user signs in through a browser, and the MCP server returns the tokens to the client on the laptop. So Formal lets the client keep a token it can’t use.
In the Notion MCP example, an encrypt policy runs on the response to POST /token from mcp.notion.com and seals access_token and refresh_token. The client stores values that start with formalsealed:. On every later MCP call, a decrypt policy opens the Authorization header before Notion sees it. This is the decrypt half, from the docs:
package formal.v2
import future.keywords.if
request := {
"action": "decrypt",
"targets": [
{"kind": "header", "name": "Authorization"}
],
"reason": "Decrypt Notion tokens for the upstream MCP server"
} if {
input.resource.hostname == "mcp.notion.com"
}
Bearer formalsealed:v1:... becomes Bearer <plaintext> for Notion, and headers without a sealed value pass through unchanged. The sealing can run in two places. On macOS, the Endpoint uses the device’s Secure Enclave, and the traffic never goes to a Connector. Otherwise the Endpoint forwards Notion traffic to a Connector, which wraps a per-process AES-256 data key with a key encryption key in your own AWS KMS, GCP KMS or Azure Key Vault (Token Encryption). When the request is authenticated, sealed values are bound to the user who made it.
You can check the result. After the OAuth flow, the docs read Claude Code’s Keychain entry:
security find-generic-password -s "Claude Code-credentials" -w | jq .
Under the Notion entry, accessToken and refreshToken both start with formalsealed:. A copy that leaves the laptop, in a log, a pasted config or an agent’s context, isn’t a token Notion will accept.
A few behaviors to plan for. Encryption fails closed: if Formal can’t seal the token, it blocks the token response, so a broken key setup stops sign-in rather than letting a plaintext token through. Decryption fails open, forwarding the request unchanged. Rotating the key encryption key is a breaking change, and clients holding formalsealed:v1: values from the old key have to sign in again. The flow itself doesn’t change: Formal leaves Dynamic Client Registration and the OAuth browser flow alone, and the client keeps using the real MCP URL. The docs expect the same approach to extend to any OAuth DCR flow, with Notion as the worked example.
Tokens in command lines, tool calls and logs
Some tokens can’t be moved, and agents sometimes paste one into a command anyway. For those, a Formal policy blocks agent tool calls whose arguments contain a GitHub token prefix (ghp_, github_pat_, gho_, ghu_, ghs_, ghr_) or a URL with credentials, and log schemas encrypt or drop command lines, HTTP fields and log.llm content on the Endpoint and Connector before Formal stores or exports the logs. The patterns catch common formats and miss secrets without a known prefix. AI DLP has the policy and what it misses.
Static keys are the other thing to retire. When the caller is a CI job or a cloud agent with a signed identity, Formal mints a federation token that expires at the earlier of the upstream JWT’s expiry and one hour, so nothing long-lived sits in the job. AI agent identity covers how that identity maps to a person.
There are limits. Sealing on the Endpoint needs macOS and its Secure Enclave; on other systems the encryption runs on a Connector. Transparent Mode and network rules, which the Endpoint uses to intercept these requests, aren’t available on Windows today. And a proxy only protects tokens in traffic it sees: a token an agent sends to a host no network rule matches goes out as it was.
RFC 6750’s abstract already says that bearer tokens “need to be protected from disclosure in storage and in transport.” With agents, storage includes the agent’s context, and what holds up there is keeping the token out of it. The Formal Endpoint page shows how injection fits with egress rules for agent sandboxes, and you can request a walkthrough on your own agents’ traffic.