API Authentication
Proving who is calling an API: API keys, HTTP Basic, bearer tokens and OAuth 2.0, mutual TLS, request signing with HMAC, and how to keep credentials safe.
Who is calling, and what may they do?
Every protected API answers two questions for every request. Authentication establishes who the caller is; authorization decides what that caller may do. The identity management overview explains both in general; this page is about the practical side: what a client actually puts in an HTTP request to prove its identity, and what the API sends back when that proof is missing or not good enough.
HTTP has a general framework for this (RFC 9110, section 11). A
client sends its credentials in the
Authorization request header, in the form
Authorization: <scheme> <credentials>. A
server that wants credentials answers 401 with a
WWW-Authenticate header naming the scheme it expects:
Basic, Bearer or another registered one.
Some methods, such as API keys in a custom header or mutual TLS,
live outside this framework, but the idea is the same.
Two rules hold for every method on this page. First, credentials are only as safe as the connection: without HTTPS anyone on the path can copy them. Second, the caller is often not a person but a program, such as a dashboard, a script or a pipeline; see Identity for Applications and Machines for how those identities are managed.
API keys
An API key is a long random string that a service issues to a client application, which sends it with every request. There is no standard for API keys: each provider picks its own header name and format. A typical request looks like this:
GET /v1/outages?area=Zwolle HTTP/1.1 Host: api.example.com X-API-Key: demo_live_4f9c2a7e81b04d6f Accept: application/json
Three things are worth knowing about API keys:
- A key identifies an application, not a user. The API learns "this is the outage dashboard", not "this is the service desk agent using it". If you need to know the person, you need user authentication (OAuth 2.0 with a user sign-in) on top.
-
Send it in a header, never in the URL. Some APIs
accept
?api_key=…in the query string, but URLs end up in browser history, proxy and server access logs, monitoring tools and theRefererheader. A header is not logged by default. - Scope and rotate. Give every consumer its own key, with only the permissions it needs (read-only where possible), so you can revoke one without breaking the others. An API should allow two valid keys per client at the same time, so a new key can be rolled out before the old one is revoked.
An API key is a bearer secret: whoever holds it is the application. It usually never expires on its own, which is why leaked keys are one of the most common causes of API incidents.
HTTP Basic authentication
Basic authentication
(RFC 7617, September 2015) is the oldest HTTP scheme: the client
joins user name and password with a colon, encodes the result in
base64 and sends it in the Authorization header. A
server that wants Basic credentials says so in its 401 response:
HTTP/1.1 401 Unauthorized WWW-Authenticate: Basic realm="Kestrel API", charset="UTF-8" Content-Length: 0
Base64 is an encoding, not encryption: it only makes arbitrary bytes safe to put in a header. Anyone who sees the header can turn it back into the password in one line, as this example shows.
import base64
# What a client sends for user "portal-sync" with password "Gr1d-Status!"
user, password = "portal-sync", "Gr1d-Status!"
token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
print("Authorization: Basic " + token)
# What anyone who sees the header can do with it
header = "Basic " + token
scheme, _, encoded = header.partition(" ")
decoded = base64.b64decode(encoded).decode("utf-8")
name, _, secret = decoded.partition(":")
print("scheme:", scheme)
print("user:", name)
print("password:", secret)
Authorization: Basic cG9ydGFsLXN5bmM6R3IxZC1TdGF0dXMh scheme: Basic user: portal-sync password: Gr1d-Status!
Basic authentication is still common for internal tools, device interfaces and some token endpoints (an OAuth client often sends its client ID and secret this way). It is acceptable only over TLS, and only for credentials that can be rotated. Its weaknesses remain: the real password travels with every request, there is no expiry and no logout, and browsers cache it for the session.
Bearer tokens and OAuth 2.0
A bearer token is a credential
that grants access to whoever presents it, with no further proof.
RFC 6750 (October 2012) defines how to send one: in the
Authorization header with the scheme
Bearer.
GET /v1/outages HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Imt… Accept: application/json
The difference with an API key is where the token comes from and how
long it lives. A bearer token is usually an
access token
issued by an
OAuth 2.0
authorization server, valid for minutes to an hour, and limited to a
set of scopes such as outages:read. If it
leaks, the damage is limited in time and in reach. The
authorization code flow
gets such a token on behalf of a signed-in user; the next section
covers callers without a user.
Machine to machine: the client credentials flow
When a program calls an API on its own behalf (a nightly sync job, a monitoring dashboard, one microservice calling another) there is no user to sign in. The client credentials flow (RFC 6749, section 4.4) covers this case: the client authenticates to the authorization server's token endpoint with its own credentials and receives an access token for itself.
POST /oauth2/token HTTP/1.1 Host: login.example.com Authorization: Basic b3V0YWdlLWRhc2hib2FyZDpkZW1vLWNsaWVudC1zZWNyZXQ= Content-Type: application/x-www-form-urlencoded grant_type=client_credentials&scope=outages%3Aread
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Imt…", "token_type": "Bearer", "expires_in": 3600, "scope": "outages:read"}
The client caches the token and reuses it until shortly before
expires_in runs out, then asks for a new one. No
refresh token is issued in this flow; the client simply repeats the
request. Instead of a client secret, the client can prove itself
with a certificate, a signed assertion or, in the cloud, a
managed identity, which removes the stored secret altogether.
JWT access tokens
Many authorization servers issue access tokens as JSON Web Tokens (RFC 9068, October 2021, profiles this for OAuth). The API can then validate a token on its own, without calling the authorization server for each request: it checks the signature with the server's published public key, and then the claims. A token is accepted only if all of these hold:
- the signature is valid, with an algorithm the API expects;
-
iss(issuer) is the authorization server you trust; -
aud(audience) names this API, so a token for another API is refused; exp(expiry) is in the future;- the scopes or roles cover the requested operation.
A JWT is signed, not encrypted: anyone holding it can read its claims. To the client an access token should be opaque; only the API it is meant for interprets it.
401 or 403: a bearer-token API in Python
Two status codes do almost all the talking in API authentication, and they are often mixed up:
-
401 Unauthorized (despite the name, it is about
authentication): the request has no valid credentials. RFC 9110
requires the server to send a
WWW-Authenticateheader with at least one challenge. Fixing the credentials can make the next request succeed. - 403 Forbidden: the server knows who you are, or does not care, and refuses anyway. Sending the same credentials again will not help; you need different permissions.
This example runs a small outage API on your own machine. It accepts
one demo token that may read outages but not delete them. The client
uses http.client, so you see the real status line and
the WWW-Authenticate header with the error codes from
RFC 6750 (invalid_token,
insufficient_scope). Python's
http.server is for learning and testing only; its own
documentation says it is not recommended for production.
import hmac
import http.client
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# The one token this demo API accepts, and what it may do. A real API
# validates a signed JWT or asks the authorization server instead.
TOKENS = {"demo-token-7f3a91": {"client": "outage-dashboard",
"scopes": {"outages:read"}}}
class OutageAPI(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # no request log lines in the output
def reply(self, status, body, challenge=None):
data = json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
if challenge:
self.send_header("WWW-Authenticate", challenge)
self.end_headers()
self.wfile.write(data)
def caller(self):
scheme, _, token = self.headers.get("Authorization", "").partition(" ")
if scheme.lower() != "bearer" or not token:
self.reply(401, {"error": "token required"}, 'Bearer realm="outages"')
return None
for known, info in TOKENS.items():
if hmac.compare_digest(token.encode(), known.encode()):
return info
self.reply(401, {"error": "token not valid"},
'Bearer realm="outages", error="invalid_token"')
return None
def do_GET(self):
if self.caller():
self.reply(200, {"outages": [{"id": "OUT-1042", "area": "Zwolle"}]})
def do_DELETE(self):
info = self.caller()
if info and "outages:write" not in info["scopes"]:
self.reply(403, {"error": "not allowed to delete outages"},
'Bearer error="insufficient_scope", scope="outages:write"')
elif info:
self.send_response(204)
self.end_headers()
server = ThreadingHTTPServer(("127.0.0.1", 0), OutageAPI)
threading.Thread(target=server.serve_forever, daemon=True).start()
def call(method, path, token=None):
conn = http.client.HTTPConnection(*server.server_address)
headers = {"Authorization": "Bearer " + token} if token else {}
conn.request(method, path, headers=headers)
response = conn.getresponse()
print(method, path, "token:", token)
print(" ->", response.status, response.reason)
if response.getheader("WWW-Authenticate"):
print(" WWW-Authenticate:", response.getheader("WWW-Authenticate"))
print(" body:", response.read().decode("utf-8"))
conn.close()
call("GET", "/outages") # no token
call("GET", "/outages", "demo-token-7f3a9") # wrong token
call("GET", "/outages", "demo-token-7f3a91") # right token
call("DELETE", "/outages/OUT-1042", "demo-token-7f3a91") # not allowed
server.shutdown()
server.server_close()
GET /outages token: None
-> 401 Unauthorized
WWW-Authenticate: Bearer realm="outages"
body: {"error": "token required"}
GET /outages token: demo-token-7f3a9
-> 401 Unauthorized
WWW-Authenticate: Bearer realm="outages", error="invalid_token"
body: {"error": "token not valid"}
GET /outages token: demo-token-7f3a91
-> 200 OK
body: {"outages": [{"id": "OUT-1042", "area": "Zwolle"}]}
DELETE /outages/OUT-1042 token: demo-token-7f3a91
-> 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="outages:write"
body: {"error": "not allowed to delete outages"}
Note the details. Without a token, the challenge carries no error
code, as RFC 6750 recommends: the client simply did not try. A wrong
token gets invalid_token. The valid token reaches the
data, but the delete is refused with 403 and
insufficient_scope, which tells the client exactly
which scope it lacks. The token is compared with
hmac.compare_digest, which takes the same time wherever
the strings differ, so response times do not leak how much of a
guess was right.
Mutual TLS
In ordinary HTTPS only the server shows a certificate. With mutual TLS (mTLS) the client also presents a certificate during the TLS handshake and proves it holds the matching private key. The server checks that certificate against the certificate authorities it trusts before any HTTP request is read. Authentication happens in the connection, not in a header.
mTLS is strong because the private key never travels: there is no secret in the request to copy. It is common between services inside a data center or service mesh, for payment and banking APIs, and for devices that each get their own certificate. The cost is operational: issuing, renewing and revoking client certificates, and making sure every proxy on the path either passes the connection through or terminates TLS and forwards the verified identity in a way the API can trust.
RFC 8705 (February 2020) combines mTLS with OAuth 2.0 in two ways: a client can authenticate to the token endpoint with its certificate instead of a secret, and the authorization server can issue certificate-bound access tokens. Such a token only works on a connection made with the same client certificate, so a stolen token is useless on its own. DPoP (RFC 9449, September 2023) reaches a similar goal at the application layer, with a signed proof in a header instead of a client certificate.
Signing requests with HMAC
A bearer token proves who you are, but not that the request was not changed on the way, and a copied token can be replayed. With an HMAC signature the client and API share a secret key that is never sent. For each request the client computes an HMAC over the important parts (method, path, a timestamp and a hash of the body) and sends only the result. The API recomputes it with its copy of the key; any change to the request, or a wrong key, gives a different value. AWS's Signature Version 4 and most webhook signatures (see Webhooks and Real-Time APIs) work this way.
import hashlib
import hmac
SECRET = b"demo-shared-secret-for-meter-gateway" # known to client and API
def string_to_sign(method, path, timestamp, body):
body_hash = hashlib.sha256(body).hexdigest()
return f"{method}\n{path}\n{timestamp}\n{body_hash}".encode("utf-8")
def sign(method, path, timestamp, body):
return hmac.new(SECRET, string_to_sign(method, path, timestamp, body),
hashlib.sha256).hexdigest()
def verify(method, path, timestamp, body, signature, now):
if abs(now - timestamp) > 300: # older than 5 minutes: replay?
return "rejected: timestamp outside window"
expected = sign(method, path, timestamp, body)
if not hmac.compare_digest(expected, signature):
return "rejected: signature mismatch"
return "accepted"
# The client signs a request at 1790000000 (a Unix timestamp)
body = b'{"meter": "M-7781", "kwh": 412.6}'
signature = sign("POST", "/readings", 1790000000, body)
print("X-Timestamp: 1790000000")
print("X-Signature:", signature)
# The API checks what arrived
print(verify("POST", "/readings", 1790000000, body, signature, now=1790000042))
tampered = b'{"meter": "M-7781", "kwh": 41.26}'
print(verify("POST", "/readings", 1790000000, tampered, signature, now=1790000042))
print(verify("POST", "/readings", 1790000000, body, signature, now=1790003600))
X-Timestamp: 1790000000 X-Signature: d1e0416b39b8186e35e8e8818a13ecb12ba28b3634c74007761a6197a650374a accepted rejected: signature mismatch rejected: timestamp outside window
The timestamp is part of the signed text, so an attacker cannot change it, and the API refuses requests outside a five-minute window. That limits replays; APIs that need more also sign a one-time value (a nonce) and remember which ones they have seen. Always compare signatures in constant time, and compute the hash over the raw bytes received, before any JSON parsing changes them.
HTTP Message Signatures
Every provider used to invent its own signing format. RFC 9421,
HTTP Message Signatures (February 2024), standardizes it:
the Signature-Input header lists which parts of the
message are covered (method, path, selected headers, a content
digest) plus parameters such as the creation time and key ID, and
the Signature header carries the value. It supports
HMAC as well as public-key signatures, so the verifier does not need
to hold the signing key. Support is growing; curl added it in
version 8.22.0 (September 2026).
Keeping credentials safe
Most API breaches do not break the authentication method; they find a credential that was left lying around. The rules:
- Never in code or Git. A key committed once stays in the history, and public repositories are scanned for secrets within minutes. See Securing Repositories for secret scanning and what to do after a leak: revoke first, clean up second.
-
Read them from the environment. The program gets
the key from an
environment variable
(for example
os.environ["OUTAGE_API_TOKEN"]) or, better, from a secrets manager that controls and logs who reads it. Local.envfiles belong in.gitignore. -
Keep them out of logs. Do not log full request
headers or URLs with keys in them; mask
Authorizationin debug output, error reports and exported traces. - Prefer short-lived credentials. A token that expires in an hour is a smaller problem than a key that never expires. Better still is no stored secret at all: managed identities and workload identity federation.
- Plan rotation. Every credential will one day need replacing, on schedule or after a leak. Design for secret rotation from the start: two valid keys at once, an owner per key, and an expiry date someone monitors.
Comparing the methods
| Method | What is sent | Identifies | If it is copied | Typical use |
|---|---|---|---|---|
| API key | The key, in a header | An application | Usable until revoked | Public data APIs, simple integrations |
| HTTP Basic | User name and password, base64 | An account | The real password is exposed | Internal tools, devices, token endpoints |
| Bearer token (OAuth 2.0) | A short-lived access token | A user or an application, with scopes | Usable until it expires | Most modern web and cloud APIs |
| Mutual TLS | Nothing in HTTP; a certificate in the handshake | A client certificate | Nothing to copy; the private key stays local | Service to service, banking, devices |
| HMAC signature | A signature per request | Holder of a shared key | Useless for other requests or after the time window | Cloud storage APIs, webhooks, payments |
The methods combine. A payment API might require mTLS for the connection, an OAuth token for the caller and its scopes, and a signature on each request. An API gateway often does the checking in one place, so the services behind it receive only requests that have already been authenticated.