APIer/06 · Authentication and security
CHAPTER 06 · API keys, JWT, OAuth 2.0, CORS

Authentication and security

A server has two separate questions to answer about every request: who sent it, and what is that caller allowed to do. This chapter works through four mechanisms that answer them, and then finishes the CORS explanation that chapters 01 and 02 left open.

§01

Authentication is not authorization

The two words look alike and mean different things. Mixing them up makes everything after this section confusing.

Two questions at the door

The first question is who are you. You show a badge and the guard checks that it is yours. That is authentication, often written authn: proving an identity.

The second question is what may you do. You say you are going to the server room, and the guard checks a list. Your badge is genuine, and it still does not open that door. That is authorization, often written authz: deciding what an identity is permitted to do.

The order never changes: authenticate first, authorize second. Until the server knows who is calling, there is nothing to check permissions against.

AUTHENTICATION
Who are you 401

Return 401 when the credential is missing, malformed, expired, or wrong. HTTP requires a 401 response to carry a WWW-Authenticate header naming the scheme the client should use, for example WWW-Authenticate: Bearer. The name of the status code is "Unauthorized", which is a historical mistake — it is the unauthenticated case. Read it as "I do not know who you are".

AUTHORIZATION
What may you do 403

Return 403 when the caller is authenticated and still not permitted. Logging in again changes nothing; someone has to grant the permission. An ordinary user trying to delete another user's post gets a 403, not a 401.

§02

API keys: the simplest credential

One long string that identifies the caller. Enough for some jobs, and an incident when it is used for the wrong one.

An API key is a random string the provider issues to you. You send it with every request, and the provider can tell which account is calling: enough to meter usage, apply rate limits, and block an abusive caller. Note what it identifies. A key identifies a project or client application, not a person. Whether ten people on your team or one script is behind that key, the provider cannot tell. If you need to know which user is acting, a key is the wrong tool.

Send the key in a header
1GET /v1/weather?city=berlin HTTP/1.1
2Host: api.weather.example
3X-API-Key: wk_live_9f8a7b6c5d4e
Do not put it in the query string (?key=…). URLs end up in server logs, browser history, and Referer headers, and a shared link then carries the key with it. Headers do not travel that way.

Code that runs in a browser cannot keep a secret

Putting a key in browser code publishes it. The user can read the source, watch the request in the Network panel, and search the bundle for strings. Minifying and obfuscating do not help, because the request goes out with the key in plain text. HTTPS does not help either: it protects the traffic from third parties on the network, not from the person operating the browser.

There is one fix. Keep the key on your own server and let it make the call.

The fix: your own backend calls the third party
🖥️Browser🚫 has no key
GET /api/weather
🏠Your backend🔑 key in an env var
X-API-Key: wk_live_9f8a…
🌐Third-party API
The browser only talks to your server. Your server holds the key and presents it. The key never reaches the page, so it never appears in the Network panel. The same hop is also where you can add caching and rate limiting.
§03

Basic authentication: sending the password itself

The oldest scheme built into HTTP, and the source of one very common misunderstanding.

Basic authentication puts username:password into the Authorization header, encoded with base64:

The request
1GET /admin/posts HTTP/1.1
2Authorization: Basic c3R1ZGVudDpzZWNyZXQxMjM=
That string looks unreadable. Use the bench below to find out whether it actually keeps anything private.
Base64 bench: type it, encode it, decode it back
Authorization: Basic c3R1ZGVudDpzZWNyZXQxMjM=

base64 is encoding, not encryption

Encoding is a public, reversible way of rewriting data so it survives transport. Anyone can undo it, and no key is involved. Encryption needs a key to undo, and its purpose is to keep the content from being read. base64 is the first kind. Basic authentication therefore has no confidentiality of its own: it relies entirely on HTTPS. Over plain HTTP it sends the password to anyone watching the connection.

It is not obsolete, though. Twilio still uses Basic today (AccountSid and AuthToken over HTTPS). It is simple, stateless, and supported by every tool.

How the server asks for credentials
1HTTP/1.1 401 Unauthorized
2WWW-Authenticate: Basic realm="Staff area"
This is the 401 from section 01, with the header that belongs on it. The header names the scheme the client should use. realm is a label for the protected area, shown by the browser in its password prompt.

What the server compares the password against

Whatever scheme sends the password, the server must not store it as text, and must not encrypt it either — anything encrypted can be decrypted by whoever holds the key. Store a hash: a one-way value computed from the password that cannot be turned back into it. Use an algorithm designed for passwords, which is deliberately slow and salts each password with random bytes: bcrypt, scrypt, or Argon2. A plain SHA-256 is the wrong choice here, because it is fast, and fast is exactly what an attacker guessing billions of passwords wants.

§04

JWT, part by part

Three base64url parts separated by dots. Opened up, it is more readable than most people expect.

After a successful login, how does the server recognize you on the next request? The traditional answer is a session: the server stores the login state and gives you an id that points at it. A JWT (JSON Web Token) turns that around. The claims about you are written into the token itself, the server signs it, and the server stores nothing. On each request it verifies the signature and reads the claims. That removes the shared session store, which is convenient when many servers handle the same user. It also gives something up. The callout below says what.

JWT bench: click any of the three parts
..
② payload · the data (readable)
{
  "sub": "42",
  "name": "Ada Lovelace",
  "role": "editor",
  "exp": 1798761600
}

This part is not encrypted. sub is the user id and exp is the expiry time in Unix seconds (2027-01-01 here). Base64URL is only a way of writing bytes as URL-safe text, so anyone holding the token can decode it. Put no phone number, no balance, and no government id in here — store an id and look the rest up on the server.

Sending a token
1GET /me/drafts HTTP/1.1
2Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiJ9.kXbG…
Bearer means the holder. Whoever presents this token is treated as the user, with no further proof required. That is what makes it convenient, and it is also why the token must travel over HTTPS and be stored carefully.

The real trade-off: a stateless token cannot be taken back

A session lives on the server, so the server can delete it. The next request with that session id fails immediately. A signed JWT is checked by recomputing the signature, not by looking anything up, so there is nothing to delete. Until exp passes, every server holding the key will accept it. If a user logs out, changes their password, or has their token stolen, the token is still valid.

So JWT is not simply "better than sessions". It trades revocation for not having to store state. The usual way to buy some of that back is two tokens: a short-lived access token, plus a refresh token that the server does store and can revoke. Some systems also keep a deny list of token ids — which is server-side state again, and that is the point.

1Normal requests

Every request carries the access token. It expires in about 15 minutes, so a stolen copy is useful for a short time only.

2It expires

The API answers 401. This is not a signal to send the user back to the login page. It is a signal to get a new token.

3Exchange it

The client posts the refresh token, which the server does store and can revoke, and receives a new pair.

4Retry

The failed request is sent again with the new access token. The user sees nothing.

Where a browser should keep the token

There is no storage option that is simply safe. In localStorage, the token is readable by any script running on the page, so a single cross-site scripting (XSS) bug — in your code or in a dependency — lets an attacker read it and send it away. In a cookie marked HttpOnly, script cannot read it, which removes that path. But the browser then attaches the cookie to requests automatically, including requests started by another site, so you need cross-site request forgery (CSRF) protection: SameSite=Lax or Strict, or a separate anti-CSRF token.

Pick one and handle its weakness on purpose. Saying "we use cookies, so we are secure" is how the second problem gets forgotten.

§05

OAuth 2.0: giving access without giving the password

A framework for delegating access to your data. Read the name carefully: it is about authorization.

The problem: another application wants to read data that belongs to you and is held by a service you already use — your photos, your calendar, your repositories. You must not hand it your password for that service, because a password grants everything, forever. OAuth 2.0 answers this by adding a party that issues limited, expiring tokens. Four roles first:

RESOURCE OWNER
Resource owner

You. The data is yours, so granting access is your decision.

CLIENT
Client

The application that wants access. It is a guest here, not the owner.

AUTHORIZATION SERVER
Authorization server

Checks your password, asks for your consent, and issues tokens.

RESOURCE SERVER
Resource server

Holds the data. It accepts a token and serves only what that token allows.

Authorization Code with PKCE, one step at a time
🧑You (browser)Resource owner
Connect my photo library
🖨️PhotoPrintClient (third party)
🛂Authorization serverphotos.example · issues tokens
🗄️Resource serverphotos.example · holds photos
Your photos live at photos.example. PhotoPrint is a separate service that prints them. It needs to read your photos, and you must not give it your photos.example password. That is the problem OAuth 2.0 solves.
1 / 7

OAuth 2.0 is not a login protocol

This is the most common mistake made about OAuth. An access token is an answer to what may the bearer do. It is not an answer to who is this user. Nothing in OAuth 2.0 requires the token to identify anybody, and a resource server that treats "this token works" as proof of identity can be fooled by a token that was issued to a different application.

The identity layer built on top of OAuth 2.0 is OpenID Connect (OIDC). It adds a second token, the ID token, which is a JWT the client is meant to validate, containing who the user is, who issued the claim, which client it was issued for, and when it was issued. When a site offers "sign in with Google", that is OIDC. The underlying exchange is the same one you stepped through above.

Which flow to use, as of today

Use Authorization Code with PKCE. PKCE (pronounced "pixy") is the step you saw: the client generates a random secret, sends its hash when starting the flow, and sends the secret itself when exchanging the code. An authorization code that is intercepted is then useless on its own. PKCE was introduced for clients that cannot keep a secret, such as mobile and browser applications, but the current recommendation is to use it for every client, including ones that do have a client secret.

Two older options are no longer options. The implicit flow, which returned the token directly in the redirect URL, is deprecated: URLs leak into history, logs, and referrers. The resource owner password credentials grant, where the application collects the user's password itself, is also deprecated — it defeats the reason OAuth exists. If a tutorial presents either one as a normal choice, it is out of date.

§06

CORS: a rule the browser applies

The red console message from chapter 02, explained completely this time.

Browsers apply the same-origin policy: script loaded from one origin may not read a response from a different origin. An origin is the scheme, the host, and the port together — if any of the three differs, it is a different origin. The rule protects you as a user. Without it, a page you visit could use the cookies your browser already holds to read your mail from another site. CORS (Cross-Origin Resource Sharing) is how a server opts out of that restriction: it states, in response headers, which origins may read its responses. Here is the full exchange:

A preflight, step by step
📄Your scriptlocalhost:3000
fetch(…) + Authorization
🛡️BrowserApplies the same-origin policy
🌐api.example.comA different origin
Your page is served from http://localhost:3000 and wants data from https://api.example.com. The scheme, the host, or the port differs, so this is a cross-origin request. The browser applies the same-origin policy to it.
1 / 7
A CORS error is not a broken API

When a CORS error appears, the request has usually reached the server and may already have been processed. The browser withheld the response from your script. What needs changing is the server's response headers, not your fetch call.

curl and Postman are not affected

The rule is enforced by browsers. Command line tools and any code running on a server ignore it. "It works in Postman but not in the page" is the clearest sign that you are looking at a CORS problem.

It does not protect your API

CORS decides whether page script may read a cross-origin response. It protects users from other web pages. It stops no scraper and no attacker, because neither needs a browser. Authentication and authorization on the server are still required.

Which requests are preflighted

A simple request goes straight out, and the browser only checks the response. It has to be GET, HEAD, or POST, carry no headers beyond a small allowed set, and if it has a body, its Content-Type must be one of three form types. Anything else is preflighted: an Authorization header, Content-Type: application/json, a custom header such as X-API-Key, or a method like PUT or DELETE. So most JSON requests you write are preflighted with an OPTIONS request first.

Notice what the same-origin policy does and does not stop. It stops your script from reading the response. It does not stop a simple request from being sent, and the server may act on it. That is why CSRF protection is a separate job from CORS.

Credentials change the rules

If the request is made with credentials: "include" so that cookies are sent, the server must answer with Access-Control-Allow-Credentials: true and must name the origin explicitly. The wildcard Access-Control-Allow-Origin: * is rejected in that case; the browser refuses the response even though the header is present. The same applies to a wildcard in -Headers and -Methods. Echoing back whichever origin asked, to work around this, means allowing every site — do not do it without a checked list.

§07

A checklist before you ship

There is no single measure that makes an API secure. These eight habits prevent most of the common mistakes.

🔒
HTTPS everywhere

Over plain HTTP, every token, key, and password in the headers travels in the clear. A modern API should have no http:// form at all.

🙈
Keys stay out of the frontend and out of git

Use environment variables or a secret manager. If a key is committed by accident, replace it — the scanners that search public repositories for keys are faster than you are.

🤐
Error messages say little

Stack traces, SQL, and internal paths describe your system to an attacker. Return the plain error format from chapter 04 and keep the detail in your own logs.

🎯
Least privilege

Give a token the smallest scope that does the job, and the shortest lifetime that is workable. The scope decides how much a leak costs.

Credentials expire

Short access token, revocable refresh token. A token that never expires is one you can never take back.

🧾
No sensitive data in a JWT payload

The payload is encoded, not encrypted. Anyone holding the token can read it. Put an id in, and look the rest up on the server.

🧮
Pin the algorithm when verifying a token

Verify against the algorithm you expect, never the one named in the token header. Configure your library to accept exactly one, and reject alg: none.

🧂
Store passwords as a slow, salted hash

bcrypt, scrypt, or Argon2. Not plain text, not encryption, and not a bare SHA-256, which is far too fast to slow an attacker down.

§08

Practice

Three tasks: decode a base64 credential, take a JWT apart, and cause a CORS error on purpose.

§09

Quiz

Nine questions on the distinctions this chapter depends on.

QUESTION 01 / 9

How do authentication and authorization divide the work?

QUESTION 02 / 9

A user is logged in normally and tries to delete another user's post. What should the server answer?

QUESTION 03 / 9

Authorization: Basic c3R1ZGVudDpzZWNyZXQxMjM= — what is that string?

QUESTION 04 / 9

Which statement about the three parts of a JWT is correct?

QUESTION 05 / 9

Access tokens are issued with a short life on purpose, from minutes to a few hours. When one expires, the client does not interrupt the user. It sends the longer-lived ____ token it is holding and receives a new pair. (one English word)

QUESTION 06 / 9

Five steps of the authorization code flow: ① you approve the request on the authorization server's page ② the client sends the code and its code_verifier to the token endpoint and receives an access token ③ you click "connect my photo library" and the browser is redirected to the authorization server ④ the client calls the resource server with the token ⑤ the authorization server sends the browser back to the client with a code. What is the correct order?

QUESTION 07 / 9

A resource server receives a valid OAuth 2.0 access token. What does the token establish?

QUESTION 08 / 9

Which statements about CORS are correct? (select all that apply)

QUESTION 09 / 9

You have a key for a paid weather API and plan to put it directly in your frontend JavaScript. Is that workable?

What to take away from this chapter
  • Authentication asks who you are; authorization asks what you may do. 401 means the server does not know who you are, and it must name the scheme to use in WWW-Authenticate; 403 means it knows and still refuses.
  • Code running in a browser cannot keep a secret. An API key that reaches the page is public. Keep it on your own server and call the third party from there.
  • base64 is encoding, not encryption. Anyone can reverse it, so Basic authentication is private only because HTTPS carries it.
  • A JWT is signed, not encrypted. The payload is readable by anyone holding the token. The signature proves the token came from a holder of the key and was not altered — and the server must check it against the algorithm it expects, not the one in the header.
  • A session can be deleted; a signed token stays valid until it expires. That is the trade-off. Pay for it with a short access token plus a refresh token the server can revoke.
  • OAuth 2.0 delegates access, not identity. An access token says what the bearer may do. OpenID Connect adds the ID token that says who the user is. Use Authorization Code with PKCE; implicit is deprecated.
  • CORS is a browser rule, not a server-side security boundary. It decides whether page script may read a cross-origin response. curl ignores it, non-simple requests are preflighted with OPTIONS, and Access-Control-Allow-Origin: * cannot be used with credentials.