Authentication is not authorisation
Authentication answers who are you. Authorisation answers what may you do. They are separate steps with separate failure modes, and collapsing them into one word — "auth" — is the reason so many API bugs are misdiagnosed. Most incidents filed as authentication problems are authorisation problems: the credential was fine, the permission check was wrong or missing.
HTTP encodes the distinction in two status codes, and getting them right is free documentation for
whoever is debugging your API at three in the morning.
401 Unauthorized means the request carried no credential, or one the
server could not accept — the name is a historical misnomer, it really means unauthenticated. RFC
9110 requires a WWW-Authenticate header on every 401, telling the client which
scheme to use. 403 Forbidden means the server knows exactly who you
are and is refusing anyway: wrong scope, wrong role, wrong tenant, wrong object. Retrying a
401 with a fresh token is sensible. Retrying a 403 with the same identity never
helps, which is precisely why the two codes must not be used interchangeably.
A useful mental model: authentication happens once per request at the edge and produces a principal — an application, a user, or a user acting through an application. Authorisation happens repeatedly, deep inside your handlers, every time that principal touches an object. The methods below are all about the first half. The section on authorisation models covers the second, and the second is where the real vulnerabilities live.
The methods, one by one
1. No authentication
Plenty of endpoints legitimately need no credential: a public status page, a currency conversion table, a documentation search. The mistake is treating "public" as "unprotected". An unauthenticated endpoint has no principal to key a quota on, which makes it the easiest thing in your estate to abuse — scraped, hammered, or used as an amplification target. Public endpoints therefore need the strongest rate limiting you can apply, keyed on IP or on a coarse fingerprint, and they need response sizes bounded so a single call cannot pull your entire catalogue.
Public also means cacheable, which is an advantage: a public endpoint can sit behind a CDN and
never touch your origin, whereas anything carrying Authorization generally cannot be shared
between callers.
2. HTTP Basic (RFC 7617)
The oldest scheme still in daily use. The client concatenates the username, a colon and the password, base64-encodes the result and sends it:
GET /v1/reports HTTP/1.1 Host: api.example.com Authorization: Basic YWxpY2U6czNjcjN0
That value decodes to alice:s3cr3t in one step with no key and no effort —
base64 is an encoding, not encryption, and you can prove it to yourself in the
Base64 encoder in about four seconds. Basic therefore has exactly one
security property, and it is TLS. Over plain HTTP it is equivalent to shouting the password.
Strengths: universally supported, zero client libraries required, works in
curl with a single flag, and the server-side check is trivial.
Weaknesses: the credential is long-lived and reusable, it is sent on every single request
so every log and proxy is a potential leak point, it cannot be scoped to a subset of the API, and
revoking it means changing the password for everything that uses it. There is no expiry.
Use it for: internal service-to-service calls on a private network, CI jobs, admin tools
behind a VPN, and the many APIs that use Basic with an API key as the username and an empty password
because it was the easiest thing to bolt onto existing HTTP clients.
3. HTTP Digest (RFC 7616)
Digest was designed to fix Basic's biggest flaw without TLS. The server issues a challenge containing a
nonce; the client hashes the username, password, nonce, method and URI together and sends
only the digest, so the password itself never crosses the wire and a captured response cannot be replayed
against a different nonce. RFC 7616 modernised it with SHA-256, superseding the MD5-only original.
In practice it is obsolete. It costs an extra round trip, it forces the server to store passwords in a form it can recompute the digest from, client and proxy support is patchy, and ubiquitous TLS removed the problem it solved. Tooling is poor enough that debugging a Digest failure is genuinely unpleasant. Mentioned here for completeness — if you are designing something new, do not choose it.
4. API keys
An API key is a shared secret string that identifies the calling application, not a
human. It travels in a custom header or in Authorization:
GET /v1/products?limit=50 HTTP/1.1 Host: api.example.com X-API-Key: pk_live_7Qa4xR2mZ9vL3nT8bK1wY6cJ
Keys are the workhorse of developer-facing APIs because they are easy to issue, easy to paste into a config file and easy for a customer to understand. Doing them properly means getting six things right.
- Generate with a CSPRNG. At least 128 bits of entropy from a cryptographically secure random source, rendered as base62 or hex. Never a UUIDv1, never a hash of the account ID, never anything a customer could predict from their own key.
- Store only a hash. Keep a SHA-256 of the key in your database, not the key itself, exactly as you would for a password — high-entropy random keys do not need a slow KDF, but they do need to be unreadable if the table leaks. Show the key once at creation and never again. You can see what a digest looks like in the hash generator.
- Prefix them. A key that starts with
pk_live_orsk_test_is instantly recognisable in a support ticket, tells you at a glance whether someone has pasted a production secret into a bug report, and — the real win — can be matched by a regular expression, so secret-scanning services can find your keys in public repositories and notify you automatically. An opaque 32-character blob is unfindable. - Rotate with overlapping validity. Allow at least two live keys per account so a customer can add the new one, deploy, and retire the old one without downtime. A rotation scheme that requires an outage will simply never be used.
- Scope each key. Read-only keys, per-environment keys, per-integration keys. A key that can do everything turns every leak into a total compromise.
- Never accept keys in the query string.
?api_key=…is written to every access log along the path, stored in browser history, cached by proxies, and — if the URL ever appears in a page — sent onward in theRefererheader to somebody else's server. Header only.
Weaknesses: a key is a bearer credential like any other, it identifies an application rather than a user so it cannot express "this end user consented", and it typically has no expiry unless you build one.
5. Bearer tokens
A bearer token is any credential where possession alone is sufficient: Authorization: Bearer
<token>, defined by RFC 6750. There is no signature proving the sender is the rightful
holder, so a stolen token is a stolen identity until it expires — which is why bearer tokens are issued
short-lived and paired with refresh tokens, and why they must never be logged.
The token itself may be an opaque random string that the server looks up, or a self-contained signed
structure. Both are "bearer"; the scheme says nothing about the format. For the full treatment — token
lifetimes, refresh flows, WWW-Authenticate error codes, revocation, storage in browsers and
the sender-constrained alternatives to plain bearer — see the dedicated
bearer token guide.
6. OAuth 2.0 and OpenID Connect
OAuth 2.0 (RFC 6749) is a delegated authorisation framework: it lets a user grant a
third-party application limited access to their data on another service without handing over their
password. The application receives a scoped, expiring access token instead of credentials, and the user
can revoke it independently at any time. OpenID Connect is a thin identity layer on top, adding an
id_token and a userinfo endpoint so the application can also learn who the user is
rather than merely what it may do on their behalf.
OAuth is what you reach for when a party you do not control needs access to data you do not own. It is substantially more machinery than an API key, and it is not the right answer for your own backend calling your own backend. Grant types, PKCE, the authorisation code flow and how the framework relates to token formats are covered in OAuth vs JWT.
7. JWT
A JSON Web Token (RFC 7519) is a token format, not an authentication method: three
base64url segments — header, claims, signature — joined by dots, carrying claims such as
sub, exp, iss and aud, signed so the recipient can
verify them without a database lookup. That statelessness is the whole appeal, and also the whole
drawback: a JWT is valid until it expires, so revoking one early requires the very state you avoided.
The critical thing to internalise is that JWT is not an alternative to OAuth. OAuth is
the protocol for obtaining a token; JWT is one possible encoding of that token. An OAuth access token can
be a JWT or an opaque string, and a JWT can be issued by a system with no OAuth anywhere in it. Anyone
asking "should we use OAuth or JWT?" has conflated two different layers —
that comparison is its own page. To inspect a token you have in hand,
paste it into the JWT decoder and read the exp claim; an
expired token explains most sudden 401s.
8. Session cookies
The browser-native approach: the user posts credentials to a login endpoint, the server creates a session and returns a cookie, and the browser attaches it to every subsequent request automatically.
Set-Cookie: sid=8f2c9ab1e7; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
The three flags carry the security. HttpOnly hides the cookie from JavaScript, which is what
makes it more resistant to token theft via XSS than anything you keep in
localStorage. Secure restricts it to HTTPS. SameSite controls
whether it is attached to cross-site requests: Lax allows it on top-level navigations only,
Strict never, and None always — the last requiring Secure.
Cookies differ from token auth in one decisive way: they are ambient. The browser sends them
whether or not your code asked it to. That makes them ideal for a first-party web app — nothing to store,
nothing to attach, revocation is a row delete — and it is exactly what creates CSRF
exposure, where another site triggers a request to yours and the browser helpfully authenticates it.
Bearer tokens are immune to CSRF precisely because they are not ambient: an attacker's page cannot add an
Authorization header it does not know. Defend cookie sessions with
SameSite plus an anti-CSRF token on state-changing requests. Note that CORS is not a CSRF
defence and CSRF is not what CORS is for — the two are constantly confused, and the
CORS guide pulls them apart.
Cookies are awkward for mobile apps, server-to-server callers and any third-party client, all of which have to emulate a cookie jar for no benefit. Use them for same-site browser apps and tokens for everything else.
9. Mutual TLS
In ordinary TLS the client verifies the server's certificate. In mutual TLS the server also demands a certificate from the client and verifies it against a trusted CA, so authentication completes during the handshake, before a single byte of HTTP is exchanged. The identity is the certificate subject, and the private key never leaves the client. RFC 8705 defines how to bind OAuth access tokens to a client certificate, which upgrades those tokens from bearer to sender-constrained — a stolen token is then useless without the matching key.
Strengths: the strongest option on this list, immune to token theft and replay, and nothing secret transits the application layer. Weaknesses: you now operate a PKI — issuing, distributing, renewing and revoking certificates, plus every expiry becoming an outage. Terminating TLS at a load balancer complicates passing the verified identity through to the application. Use it for: banking and open-banking APIs, payment networks, and service-to-service traffic inside a service mesh where a sidecar handles certificate rotation automatically and the cost largely disappears.
10. HMAC request signing
Signing is the only scheme here in which the secret never crosses the wire. The client builds a canonical string from the parts of the request that must not be tampered with — method, path, sorted query, selected headers, a timestamp, and a hash of the body — computes an HMAC of it with a shared secret, and sends the resulting signature alongside the key identifier:
POST /v1/transfers HTTP/1.1 Host: api.example.com X-Timestamp: 1793491200 X-Key-Id: AKIA7QA4XR2MZ9VL Authorization: HMAC-SHA256 Signature=9f86d081884c7d659a2feaa0c55ad015a... canonical string signed: POST /v1/transfers x-timestamp:1793491200 sha256(body)=b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
The server recomputes the same string from the request it actually received and compares signatures in constant time. Anything an attacker modifies in flight — a changed amount, a different path — breaks the signature. Because the timestamp is inside the signed material, the server can reject anything outside a narrow window (typically five minutes) and remember recently seen nonces, which defeats replay. AWS Signature Version 4 is the canonical production example; several payment and telephony APIs use the same shape.
Compared with bearer tokens the gains are real: an intercepted request cannot be replayed, cannot be modified, and cannot be reused against a different endpoint, and because only the holder of the secret could have produced the signature you get a measure of non-repudiation. The cost is equally real. Canonicalisation is fiddly and every client must agree on it byte for byte — a trailing slash, a differently-ordered query string, a header your proxy added or normalised, a body re-serialised with different whitespace, or a client clock two minutes out will each produce a valid-looking request that fails to verify with an unhelpful error. Debugging signing bugs is the least pleasant work in this article, which is why most public APIs settle for bearer tokens over TLS.
The same idea runs in reverse for webhooks: the sender signs the payload with a secret you share, and your endpoint verifies the signature before trusting the event — otherwise anyone who learns your callback URL can post fake events into your system. Inspect real signed deliveries with the webhook tester.
Comparison table
| Method | Sent on the wire | Identifies | Revocable | Scoped | Expires | Replay-resistant | Browser-friendly | Cost to build |
|---|---|---|---|---|---|---|---|---|
| None | Nothing | Nobody | n/a | No | n/a | n/a | Yes | None |
| Basic | Password (base64) | User or app | Password change | No | No | No | Yes | Very low |
| Digest | Digest of password | User | Password change | No | Per nonce | Partly | Yes | Medium |
| API key | Shared secret | Application | Yes, per key | If designed | Rarely | No | Poorly | Low |
| Bearer token | Token | User or app | Yes, if stateful | Yes, scopes | Yes | No | Yes | Medium |
| OAuth 2.0 | Access token | User via app | Yes | Yes, scopes | Yes | No | Yes | High |
| JWT (format) | Signed claims | Whatever it says | Hard | Via claims | Yes, exp | No | Yes | Medium |
| Session cookie | Session id | User | Yes, immediately | Via roles | Yes | No | Native | Low |
| mTLS | Certificate, no secret | Client machine | CRL or short certs | Via subject | Cert lifetime | Yes | Awkward | Very high |
| HMAC signing | Signature only | Application | Yes, per key | Per key | Per request | Yes | Awkward | High |
"Browser-friendly" means usable from front-end JavaScript without exposing a long-lived secret in code the user can read. Anything marked awkward or poorly should live behind your own backend, never in a single-page app bundle.
Which one should you use?
| Scenario | Recommended | Why |
|---|---|---|
| Internal service to service | mTLS in a mesh, otherwise a scoped API key over TLS | No human is present, so no consent flow is needed. If a sidecar already rotates certificates, mTLS is nearly free; if not, a key is honest and cheap. |
| Public developer API | API keys, or OAuth client credentials for larger customers | Developers understand keys, they paste into config files, and per-key scoping plus rotation gives you a revocation story. |
| First-party single-page app | Session cookie with HttpOnly, Secure, SameSite |
Same site means the browser does the work, nothing is exposed to JavaScript, and logout is instant. Add CSRF tokens on writes. |
| Mobile app | OAuth authorisation code flow with PKCE, short access tokens plus refresh | No cookie jar, no embeddable secret in a binary anyone can unpack, and tokens survive being stored in the platform keychain. |
| Third party accessing user data | OAuth 2.0, with OIDC if you also need identity | The only model where the user grants scoped access without sharing a password and can revoke it later independently. |
| CI/CD scripts | Short-lived tokens from the platform, else a scoped key in a secret store | Prefer credentials minted per job with a lifetime of minutes over a static secret sitting in a repository variable forever. |
| Webhooks you send or receive | HMAC signature with a timestamp window | The receiver has no session with you, so the payload must prove its own origin and freshness. |
Authorisation models, briefly
Once you know who the caller is, you need a model for what they may do. Three are common, and they compose rather than compete.
- Scopes constrain the token:
orders:read,invoices:write. They express what an application was permitted to ask for, and they are a ceiling, not a grant — a token withorders:readcan still only read the orders its owner could read. - Roles (RBAC) constrain the subject: admin, support, viewer. Simple to reason about and easy to audit, but roles multiply once real organisations need per-team and per-region variants.
- Attributes (ABAC) evaluate a policy over facts about the subject, the object and the context — department, ownership, time of day, IP range. Far more expressive, considerably harder to test and to explain to a user who was unexpectedly denied.
None of these substitutes for the fourth check, and the fourth check is the one that gets skipped:
per-object ownership. Every handler that accepts an identifier must verify that the
authenticated principal is entitled to that specific object. Failing to do so is
broken object level authorisation — listed as API1, the number-one entry in the OWASP
API Security Top 10, and long known as IDOR. The shape is always the same: a valid token for user A,
a request for GET /v1/orders/1084, and an order belonging to user B returned in full because
the code checked the token and then trusted the path parameter. Sequential integer identifiers make it
trivial to enumerate; UUIDs raise the effort but are not an access control. The fix is not obscurity, it
is scoping every query by owner at the data layer — WHERE id = ? AND account_id = ? — so an
unauthorised object cannot be loaded even by accident. Try changing an ID in a request from the
REST API tester against your own staging environment; if it returns
somebody else's data, you have found your API1.
Transport and hygiene
- TLS everywhere, no exceptions. Every scheme above except mTLS and HMAC signing
collapses entirely without it. Add
Strict-Transport-Securityso a client that once reached you over HTTPS never tries plaintext again, and redirect rather than serving anything on port 80. - Never log the
Authorizationheader. Or cookies, or request bodies from a login endpoint. Add them to your logger's redaction list before your first deploy, not after your first incident — and remember your reverse proxy, APM agent and error tracker each have their own capture rules. - Compare secrets in constant time. A naive equality check returns faster on an early
mismatch, which leaks the secret byte by byte to a patient attacker. Use your language's
hmac.compare_digestequivalent for keys, signatures and tokens alike. - Keep credentials out of URLs. Paths and query strings end up in access logs, browser
history, bookmarks, chat previews and
Refererheaders. Headers do not. - Rotate on a schedule, and practise it. Overlapping validity windows are what make rotation possible without downtime. A rotation procedure that has never been rehearsed does not exist.
- Least privilege and short lifetimes. Every credential should be able to do the smallest useful thing for the shortest useful time. These two properties turn a compromise into an incident rather than a catastrophe.
- Return a proper challenge. A
401withoutWWW-Authenticateis a spec violation and leaves generic clients unable to respond correctly.
Debugging authentication failures
| Symptom | Likely cause | What to check |
|---|---|---|
401 with no WWW-Authenticate |
Framework rejecting before the auth middleware, or a misimplemented challenge | Whether the header reached the app at all; a gateway may be answering on the origin's behalf |
| 403 with a token you know is valid | Authorisation, not authentication — missing scope, wrong role, wrong tenant | Decode the token and compare its scopes and aud against what the endpoint demands |
Works in curl, fails in the browser |
CORS: Authorization not permitted in the preflight, or the origin is not allowed |
The OPTIONS response headers — see the CORS guide |
| Header appears to vanish in production only | A proxy, load balancer or CDN stripping unknown or Authorization headers |
Echo the received headers from a debug endpoint; compare direct-to-origin against through-the-edge |
| Intermittent failures, often at deploy time | Clock skew — signature windows and exp/nbf claims are time-sensitive |
NTP on both machines; a 90-second drift breaks a 60-second signing window |
| Token rejected after copy and paste | Trailing whitespace, a newline, a smart quote, or a truncated middle from a wrapped terminal | Length of the string, and that it still has exactly two dots if it is a JWT |
| Everything 401s right after it previously worked | Expiry, or a rotated key still cached in a client | The exp claim in the JWT decoder |
The fastest way to isolate any of these is to take the browser and your application out of the loop. The
Auth tab in the API tester builds the header for you: choose Bearer and
paste a token to get Authorization: Bearer …, choose Basic and enter a
username and password to have the base64 encoding done correctly, or choose API key to
set a custom header such as X-API-Key with the value you specify. Because requests are sent
server-side rather than from your browser, CORS cannot interfere — so if a call succeeds in the
REST API tester and fails in your front end, the credential is fine and
the problem is in the browser. Compare the two request header sets before touching any code; the
difference is nearly always the answer.
Frequently asked questions
What is the difference between authentication and authorisation?
Authentication establishes who the caller is; authorisation decides what they may do. HTTP splits them into 401 for a missing or invalid credential and 403 for a valid credential without permission.
Is an API key the same as a bearer token?
They look alike on the wire, but a key is usually a long-lived secret identifying an application, while a bearer token is short-lived, issued by an authorisation server and carries scopes.
Is HTTP Basic authentication secure?
Only over TLS. Base64 is encoding, not encryption. It is acceptable for internal services and CI, but offers no scoping, no expiry and no revocation short of changing the password.
Should API keys go in a header or a query string?
Always a header. Query strings leak through access logs, browser history, proxies and the
Referer header sent to third-party sites.
Why does my token work in curl but fail in the browser?
Almost always CORS — the server did not permit the Authorization header in the preflight,
or did not allow your origin. The request usually reached the server; the browser just refused to hand
you the response.
What is the most common API security flaw?
Broken object level authorisation — API1 in the OWASP API Security Top 10, also called IDOR. The token is checked but object ownership is not, so user A reads user B's records by changing an identifier.