Juan Convers

Web Authentication and Authorization 101


Disclaimer: This post is different from the short ones I usually write here. It’s long and a bit technical, so if you’re not interested in how web authentication and authorization work under the hood, feel free to skip this one.

Last week, while I was helping a colleague who doesn’t have much experience with OAuth, I realized that it only makes sense once you understand the fundamentals of how a website or web application tells who you are apart from what you’re allowed to do.

So the objective of this post is to explain the basics of authentication and authorization, how they evolved over time and how they relate to OAuth 2.0 and OpenID Connect today. I have used all these methods during my career, so I thought it would be useful to organize them in a single post.

Step 1 Basic Auth password on every request Step 2 Sessions log in once, get a cookie Step 3 API keys credentials for programs Step 4 JWT a token with its own proof Step 5 OAuth 2.0 delegated, limited access Step 6 OpenID Connect standard login on top of OAuth each step fixes something the previous one couldn’t do

Authentication vs authorization

Authentication answers “who are you?” Authorization answers “what are you allowed to do?” I once read a good analogy about checking into a hotel, and I will use it here to explain the difference.

Picture yourself checking in:

  1. At the front desk you show your passport. The receptionist checks that you are the person on the booking. That is authentication.
  2. They hand you a key card. The card opens room 202, the gym and the pool, but not room 403 or the kitchen. That is authorization.
Authentication “Who are you?” PASSPORT The front desk checks your passport against the booking Authorization “What are you allowed to do?” KEY CARD Room 202 Gym Pool Room 403 Kitchen The doors only ever check the card

Two details from this analogy matter for everything that follows:

  • Ideally, the door never sees your passport. It only checks the card. Later in this post, the card will be a session cookie or a token. The first scheme we’ll look at, Basic Auth, doesn’t work like this yet: it shows the passport at every door, and that’s exactly its weakness.
  • The card can be given to someone else. If you hand it to a friend, the door lets them in. Whoever holds the card has the access. Security people call this a bearer credential, and it explains why we protect tokens so carefully.

Authentication almost always comes first: you can’t decide what someone may do until you know who they are. But they are separate problems, solved by separate parts of a system, and mixing them up is the root of most confusion about OAuth.

Step 1: HTTP Basic Auth

The simplest scheme the web has is HTTP Basic Authentication. The client sends the username and password with every single request.

It works like this:

  1. The browser asks for a protected page.
  2. The server replies 401 Unauthorized with the header WWW-Authenticate: Basic realm="admin". The browser shows its grey login pop-up.
  3. The browser joins username:password, Base64-encodes it and resends the request with an Authorization header.
Browser Server 1 GET /admin 2 401 Unauthorized · WWW-Authenticate: Basic 3 GET /admin · Authorization: Basic anVhbjpteXBhc3N3b3Jk 4 200 OK anVhbjpteXBhc3N3b3Jk → Base64 decode → juan:mypassword
GET /admin HTTP/1.1
Host: example.com
Authorization: Basic anVhbjpteXBhc3N3b3Jk

anVhbjpteXBhc3N3b3Jk is just juan:mypassword in Base64. Base64 is encoding, not encryption: anyone who sees the header can decode it in one second. That’s why Basic Auth is only acceptable over HTTPS.

Why it’s still around: it’s trivial to implement, every HTTP client supports it, and it’s fine for quick internal tools, a staging site, or a machine calling an API.

Why it isn’t enough:

  • The real password travels on every request, so every request is a chance to leak it.
  • There’s no clean way to log out; the browser keeps resending the credentials until you close it.
  • There’s no way to give limited access. Whoever has the password can do everything you can.
  • The server must check the password every time, which is slow if passwords are properly hashed.

In the hotel analogy: there is no key card. You show your passport at every door.

Step 2: Sessions and cookies (log in once)

Sessions fix the biggest problem of Basic Auth: you send your password once, and get a key card back.

  1. You fill in a login form and POST your username and password.
  2. The server checks the password against a stored hash.
  3. If it matches, the server creates a session: a record in its database or cache, like session 7f3a… → user 42, expires in 2 hours.
  4. It sends the random session ID back in a cookie.
  5. The browser attaches that cookie to every following request. The server looks up the ID and knows who you are.
Browser Server Session store POST /login user + password password sent once save 7f3a… → user 42 Set-Cookie: session=7f3a… every request after that GET /orders Cookie: session=7f3a… look up 7f3a… user 42 200 OK your orders
HTTP/1.1 200 OK
Set-Cookie: session=7f3a9c2e81b4; HttpOnly; Secure; SameSite=Lax

Those three flags are worth knowing early:

  • HttpOnly — JavaScript can’t read the cookie, so a cross-site scripting (XSS) bug can’t simply steal it.
  • Secure — the cookie is only sent over HTTPS.
  • SameSite — the browser doesn’t attach it to requests started by other sites, which blocks most cross-site request forgery (CSRF).

What we gained: the password crosses the network once, logout is easy (delete the session on the server), and the server can revoke a session at any time.

What it costs: the server has to remember every session. That’s fine for one web app, but awkward when you have mobile apps, many microservices, or several domains that all need to know who the user is. Cookies are also tied to a browser and a domain, so they’re a poor fit for APIs called by other programs.

Step 3: API keys and bearer tokens (credentials for programs)

When a program talks to an API, there’s no login form and often no browser. The usual answer is a long random string the program sends with each request.

An API key identifies the calling application, usually a project or account, rather than a person. You generate it in a dashboard and paste it into your server’s config:

GET /v1/forecast?city=Paris HTTP/1.1
X-API-Key: demo_key_not_a_real_secret_123

A bearer token is the same idea with a standard header. “Bearer” means exactly what the hotel card taught us: whoever bears it gets in.

GET /v1/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Your program Someone who copied the token API checks the token only Bearer eyJhbGci… Bearer eyJhbGci… Whoever bears it gets in, so keep it secret and short-lived

Rules of thumb:

  • Treat keys and tokens like passwords. Never commit them to Git, never put them in front-end JavaScript, never log them.
  • Give each key the smallest set of permissions it needs, and rotate it if it leaks.
  • Prefer short-lived tokens over long-lived keys: a token that expires in an hour is worth much less to a thief.

That last point raises a question. If tokens are short-lived and handed out by some login service, how does the API know a token is real without calling that service every time? One popular answer is the JWT.

Step 4: JWT (a token that carries its own proof)

A JSON Web Token is a token the API can verify by itself, without asking anyone. It’s three Base64URL pieces joined by dots: header.payload.signature.

// header
{ "alg": "RS256", "typ": "JWT" }

// payload (the "claims")
{
  "iss": "https://login.example.com",
  "sub": "user-42",
  "aud": "https://api.example.com",
  "scope": "mail:read",
  "exp": 1793620800
}

The login server signs the header and payload with its private key. The API holds the matching public key. If anyone changes even one character of the payload, the signature no longer matches and the API rejects the token. The API checks the signature, the issuer (iss), the audience (aud) and the expiry (exp), and then it knows who the user is (sub) and what the token allows (scope).

eyJhbGciOi… header {"alg":"RS256"} . eyJpc3MiOiJodHRwczovL… payload (claims) sub · aud · scope · exp . SflKxwRJSMeKKF2… signature proof nothing changed Login server signs header + payload with its private key API verifies with the public key signature iss aud exp token no call back to the login server

Things that trip people up:

  • A JWT is signed, not encrypted. Paste one into a JWT debugger and you can read everything. Never put secrets in the payload.
  • Always verify the signature with a well-known library, and reject "alg": "none".
  • You can’t easily revoke a JWT. It stays valid until it expires, so keep access tokens short-lived (minutes to an hour).
  • JWT is a format, not a protocol. It says what a token looks like, not how you get one. That’s the job of OAuth.

Delegation

Everything so far is about you accessing your stuff. OAuth solves a different problem: letting another application access your stuff, on your behalf, with limited rights.

Back to the hotel. A friend arrives before you and needs to get into your room to drop off their luggage. The lazy answer is to give them your passport and your key card. That’s a terrible idea:

  • Your friend can now do everything you can: open your room, charge room service to your bill, even check out in your name.
  • You can’t give them only your room, or only until tonight.
  • To take the access back, you have to cancel your card at the desk, and every other copy you handed out stops working too.
  • If your friend loses them, whoever finds them is you, as far as the hotel is concerned.

What you really do is go to the front desk and ask for a second card. The receptionist checks that it’s really you, asks what the card should open, and issues a valet key: it opens room 202 only, it stops working tomorrow, and you can cancel it at the desk at any time. Your friend never sees your passport. OAuth is the protocol for issuing valet keys.

Hand over your passport and card You Friend passport + key card what your friend can do at the hotel: Room 202 Gym & pool Room service bill Check out Everything, as if they were you Ask the desk for a valet key You Friend valet key card what your friend can do at the hotel: Room 202 Gym & pool Room service bill Check out Room 202 only · until tomorrow · cancellable

Step 5: OAuth 2.0 (delegated authorization)

OAuth 2.0 is a framework for an app to get an access token that lets it call an API on a user’s behalf, without ever seeing the user’s password. Notice the word: authorization. OAuth is about what the app may do, not about who the user is.

Now the same idea on the web. Say it’s the evening before your flight and you want to print your flight tickets. They’re in your Gmail, and the printer in the business center has a touch screen with a “Print from Gmail” button. Without OAuth, the only way for the printer to get your tickets would be for you to type your Google password into it. From then on it could do anything you can: read all your email, send messages in your name, even change your password. That’s the passport hand-over again, and it has a name: the password anti-pattern. With OAuth, the printer asks Google for a valet key instead: permission to read your email for a short time, which you can cancel from your Google account.

The four roles

Role What it is Hotel Business center example
Resource owner The user who owns the data You You
Client The app that wants access Your friend The business center printer
Authorization server Logs the user in, asks for consent, issues tokens The front desk Google’s login and consent screens
Resource server The API that holds the data The door of room 202 Gmail API

The client asks for scopes, named permissions such as gmail.readonly. The authorization server shows the user a consent screen (“Hotel Printer wants to view your email messages”) and the user approves or refuses. The token that comes out is limited to those scopes. That’s the valet key: one door, limited time, cancellable.

The authorization code flow, step by step

This is the flow you’ll use for almost every real app:

  1. You tap “Print from Gmail” on the printer’s touch screen.
  2. The printer’s screen sends you to Google’s sign-in page (the /authorize endpoint), passing along its client_id, the scope, a redirect_uri, a random state value and a PKCE code_challenge (more below).
  3. You log in at Google, not in the printer’s app, and approve the consent screen.
  4. Google sends you back to the printer’s redirect_uri with a short-lived, single-use authorization code.
  5. The printer’s backend sends that code to Google’s /token endpoint, together with its client credentials and the PKCE code_verifier. This call happens server to server, not through the browser.
  6. Google returns an access token (and often a refresh token).
  7. The printer calls the Gmail API with Authorization: Bearer <access token>, finds the email with your flight tickets and prints them.
FRONT CHANNEL · through the browser BACK CHANNEL · server to server You + browser resource owner Hotel printer client Google login authorization server Gmail API resource server 1 Tap “Print from Gmail” 2 redirect to /authorize browser → Google: client_id · scope · state · code_challenge 3 log in + approve consent 4 redirect back to the printer ?code=SplxlOBe&state=xyz code 5 POST /token code + code_verifier + secret 6 access token (+ refresh token) 7 GET /messages?q=flight Authorization: Bearer … flight tickets

Read it top to bottom: everything above step 5 goes through the browser; the tokens only ever travel on the highlighted server-to-server lines.

Why the extra hop with the code? The browser’s address bar, history and logs are leaky places. Only the short-lived code passes through them; the valuable token is handed over on a direct, authenticated back-channel.

PKCE and state

state protects you from being handed someone else’s key. Before sending you to Google, the printer makes up a random value, remembers it, and adds it to the request. Google hands it back unchanged in step 4, and the printer only accepts the code if the value matches. Without it, an attacker could start the flow with their own Google account, stop just before step 4, and trick you into opening that last link. The printer would then be connected to the attacker’s account, and anything you saved through it would end up with them. This is a kind of cross-site request forgery (CSRF).

In the hotel: when you ask the desk for a valet key, they give you a numbered ticket, and you only accept a card that comes with your number. Nobody can slip you the key to their room and have you leave your luggage there.

Printer remembers from step 2: state=7Kq2 Google, after your login Link sent by an attacker ?code=Splx…&state=7Kq2 ?code=Evil…&state=9xZp matches rejected step 4: a code comes back to the printer the printer only accepts the answer to a request it started itself

PKCE (“pixie”) makes a stolen code useless. The code travels through the browser, inside a URL, so it can leak through browser history, logs, or, on phones, another app that registers the same return address. With PKCE, the client invents a secret code_verifier, sends only its hash (code_challenge) in step 2, and reveals the verifier in step 5. The hash can’t be turned back into the verifier, so someone holding only the code can’t finish the exchange.

In the hotel: when you order the valet key, you tear a ticket in half, give one half to the desk and keep the other. The desk gives you a pickup slip (the code), and to collect the card you have to show the slip and the matching half. A thief who steals the slip still can’t collect the card.

PKCE was built for mobile and single-page apps, which can’t keep a client secret: anything shipped inside an app or in JavaScript can be pulled out by whoever downloads it. Today it’s recommended for every client.

Authorization server code_verifier random secret, kept by the printer SHA-256 code_challenge step 2 remembers the challenge code + verifier step 5 · printer SHA-256(verifier) = challenge? token stolen code only attacker rejected

Other grant types

Grant Use it when Status
Authorization code + PKCE A user is present: web, mobile, single-page apps The default
Client credentials Machine-to-machine, no user involved (a cron job calling an API) Recommended
Device authorization Devices with no keyboard: smart TVs, CLIs (“go to this URL and enter code ABCD”) Recommended
Refresh token Getting a new access token without asking the user again Recommended
Implicit Old single-page apps; token returned straight in the URL Deprecated
Resource owner password App collects the user’s password itself Deprecated

Access tokens and refresh tokens

Access tokens are short-lived on purpose, often minutes to an hour. When one expires, the client sends its refresh token to the /token endpoint and gets a fresh access token without bothering the user. Refresh tokens are long-lived and powerful, so they stay on the server (or in secure device storage), and good authorization servers rotate them on every use. When the user clicks “Remove access” in their Google account, the refresh token is revoked and the app is cut off.

access token · 1 h access token · 1 h refresh access token · 1 h refresh access token · 1 h refresh refresh token · long-lived · stays on the server user clicks “Remove access” time the user only logs in once

An access token can be a JWT the API verifies itself, or an opaque random string the API checks with the authorization server’s introspection endpoint. OAuth deliberately doesn’t say which. And most importantly: the access token is meant for the API, not for the client. The client shouldn’t open it and treat its contents as proof of who the user is.

Step 6: OpenID Connect (authentication on top of OAuth)

Once OAuth existed, everyone wanted to use it for “Sign in with Google”. The trouble is that OAuth only says “this app may read your email”. It doesn’t reliably say who approved that, when, or for which app. Teams built their own shaky login schemes on top, often by calling some “get profile” API with the access token, and some of those were exploitable.

OpenID Connect (OIDC, finalized in 2014) fixes this by adding a thin, standard identity layer to the same authorization code flow:

  • The client adds the scope openid (plus optional profile and email).
  • The /token response now contains a third thing: an ID token.
  • The ID token is always a signed JWT, written for the client, describing the login event.
{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "aud": "hotel-printer-client-id",
  "exp": 1793620800,
  "iat": 1793617200,
  "nonce": "n-0S6_WzA2Mj",
  "email": "juan@example.com",
  "name": "Juan"
}

The client checks the signature, that iss is the provider it expected, that aud is its own client ID, that it hasn’t expired, and that nonce matches the one it sent (this prevents replaying an old token). Then the user is logged in, identified by the pair iss + sub, which never changes even if the email does.

OIDC also standardizes the plumbing around it:

  • a UserInfo endpoint to fetch extra profile claims with the access token;
  • a discovery document at /.well-known/openid-configuration listing every endpoint and the public keys (JWKS), so libraries configure themselves from one URL;
  • options for logout and session management.

The one-liner to remember:

Token Who reads it What it says
ID token The client app Who the user is and how they just logged in (authentication)
Access token The API What the app is allowed to do (authorization)
Refresh token The authorization server “Please give me a new access token”
/token response with scope=openid ID token read by Client app (printer) who the user is · authentication access token read by Gmail API what the app may do · authorization refresh token read by Authorization server “a new access token, please”

In hotel terms: the ID token is the receptionist’s note to the app saying “we checked this guest’s passport at 14:02”. The access token is the key card the doors read.

Summary

Each step on this path fixed something the previous one couldn’t do, and the hotel has been with us the whole way:

Mechanism Hotel version Use it for
Basic Auth Show your passport at every door Internal tools and quick scripts, over HTTPS only
Session cookie Passport once at the desk, key card after Classic server-rendered web apps
API key A supplier’s badge: it names a company, not a person Programs calling a third-party API
Bearer token A key card that works for whoever holds it Sending an access token to an API
JWT A key card with a hologram the door can check by itself Most access tokens, and every ID token
OAuth 2.0 A valet key from the front desk Letting the business center printer read your email to print your tickets, without your password
OpenID Connect The desk’s signed note telling an app who you are “Sign in with Google”, company single sign-on

Next time my colleague and I go through our OAuth setup, I hope all those redirects, codes and tokens will look less like magic and more like a hotel front desk doing its job.

PHP2Shell: A Long Week of Patching


The last couple of days at work have been intense. On Tuesday details of a new PHP vulnerability started leaking online, and by Wednesday afternoon the official patches were out. Everything else on the board stopped. For two days the only questions were: where do we run PHP, which version is it, and is it patched yet?

The bug has a name, PHP2Shell, and it is one of the most serious problems the web has seen in years.

What is PHP?

PHP is a programming language that runs on the server. When you open a web page, PHP builds the HTML before it reaches your browser. It was created by Rasmus Lerdorf in 1994 and is still everywhere. WordPress, Drupal, Joomla, Laravel, Symfony, Magento, and MediaWiki (the software behind Wikipedia) are all written in it.

The numbers are impressive. According to these PHP statistics from September 2026:

  • PHP runs on 70.2% of all websites whose server-side language is known (W3Techs).
  • Among PHP sites, 63.4% use PHP 8, 28.7% still use PHP 7 and 7.9% are still on PHP 5.
  • Packagist, PHP’s package registry, lists 466,248 packages with almost 197 billion installs since 2012.
  • About 19% of professional developers worked extensively with PHP last year (Stack Overflow 2025), and 64% of PHP developers use Laravel (JetBrains 2025).

PHP may not be fashionable, but it powers a huge part of the internet. That is exactly why a bug in PHP itself is such a big deal.

What was the vulnerability?

PHP2Shell is actually two bugs, CVE-2026-109618 and CVE-2026-109619, both rated 9.2 out of 10:

  1. CVE-2026-109618: when PHP runs out of memory, for example during a huge file upload, it can be interrupted while its internal request data is only half built. The cleanup step then treats that broken data as valid.
  2. CVE-2026-109619: some internal state survives after its memory has been freed. The next request on the same worker can use that stale state, and the upload progress feature is one way to reach it.

Both lead to remote code execution, which means an attacker can run their own commands on your server. Here is why it was so bad:

  • No login needed. A single anonymous request is enough to attack, and it happens before any application code runs. It doesn’t matter how secure your own code is.
  • It’s in PHP itself, not in a plugin or framework, so it affected PHP-FPM, Apache mod_php, php-cgi, FrankenPHP and LiteSpeed, from PHP 5.4 all the way to 8.5.
  • Attackers were early. Amazon saw an exploitation attempt on October 17, four days before the public disclosure. The details then leaked on October 20, a day ahead of schedule.
  • Old versions don’t get proper fixes. PHP 7.4, 8.0 and 8.1 only received source patches, and nothing older got anything. With almost 37% of PHP sites still on PHP 7 or 5, many servers can’t simply “update”.

As a precaution, government sites in Europe and Canada, many Wikimedia services and even the Internet Archive went offline for a while. Interestingly, the bug was found with the help of AI models. One of the out-of-memory problems had been spotted back in 2024, but without a working exploit it was never fixed.

Patch everything, now

So what did we developers do? We patched as fast as we could. The fixed versions are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. If you run anything with PHP, even a small blog on shared hosting, check today that it’s on a patched version.

Lets see what the next week will bring in this new crazy world of LLM driven security research and exploitation.

Do we still need axios?


Yesterday a hacker compromised the account of the lead developer of axios — the npm package with around 100 million weekly downloads — and published two malicious versions that included a remote access Trojan targeting macOS, Windows and Linux.

The malicious code was pulled in through a staged dependency called "plain-crypto-js" and was designed to self-destruct after execution. It was only live for about three hours, but that was enough: security firm Huntress reported the first infection on a monitored endpoint just 89 seconds after the compromised version was published.

According to StepSecurity, the malicious dependency was staged 18 hours in advance, three payloads were pre-built for three operating systems, and both release branches were poisoned within 39 minutes of each other. Google's security team has linked the attack to a North Korean group that targets cryptocurrency theft.

This got me thinking: do we actually still need axios? The original reason it became so popular was that it gave you a clean, consistent API for making HTTP requests that worked the same way in the browser and in Node.js. But Node.js has had native fetch since version 18, and it's been stable for a while now. The browser has had it for years. So the problem axios originally solved is basically gone.

For my typical axios usage, I wrote a simple fetch wrapper called fetchios that mirrors the axios API — same .get(), .post(), .create(), interceptors and all. It works as a drop-in module so I don't have to change the code everywhere, just copy the file into utils, swap the import, and remove axios from my dependencies.

Every dependency you add is a potential attack surface — and this incident is a perfect reminder of that. Maybe it's time to stop running npm install axios by inertia and check what the platform already gives you.

Another day running a VPS


I run a small VPS for some personal projects, and I often check the SSH logs to see who’s trying to break in. I have the standard setup: key-based authentication, password login disabled, a non-default SSH port, and fail2ban configured to block IPs after a few failed attempts. But that doesn’t stop the bots from trying.

This week, I decided to do a little analysis of the SSH logs to see where the attacks are coming from, and the results are not surprising but still interesting.

Nearly 31,000 failed login attempts from the top 20 IPs alone. Russia takes the lead with 45% of attacks (20,742 attempts), followed by the US at 30% (8,163 attempts). One particularly persistent Russian IP tried over 9,600 times. Points for determination, I guess?

The geographic diversity is almost impressive: Russia, US, China, Brazil, and South Korea are all represented. It’s like a World Cup of “please let me into your server.”

This is just life when you have an SSH server exposed to the internet. I decided to add some additional security measures this week after seeing the data, but I wanted to share the conclusion of this analysis as a public service to anyone else who might be running a VPS with SSH access.

Stay safe out there.


Top 20 most common IP addresses

Rank IP Address Country Attempts % bar
1 176.120.22.47 Russia 9,621 ████████████████
2 209.38.216.89 United States 5,613 █████████
3 45.140.17.124 Russia 4,013 ███████
4 176.120.22.13 Russia 2,271 ████
5 87.121.84.136 Russia 2,252 ████
6 91.202.233.33 Russia 1,166 ██
7 167.99.72.161 United States 1,100 ██
8 222.120.161.213 China 1,087 ██
9 45.135.232.92 Russia 631 █
10 210.79.142.221 South Korea 602 █
11 165.22.216.148 United States 553 █
12 216.10.242.161 United States 436 █
13 47.251.142.10 China 360 █
14 5.104.86.151 Russia 300 █
15 45.148.10.121 Russia 288
16 68.183.234.194 United States 237
17 181.116.220.140 Brazil 232
18 209.38.85.78 United States 224
19 189.50.142.82 Brazil 222
20 157.173.199.44 Russia 193

Aircraft Hijacking Statistics


Last night, when I was reading about the EgyptAir hijacking, I was thinking that fortunately this type of incident is now very rare. I remember that when I was a kid, this was one of the clichés in the American movies of the 80s (e.g., Delta Force), but with the tough security measures in place at most airports, this doesn't happen as often.

Wikipedia has a detailed compilation of aircraft hijacking incidents, and there is definitely a decrease compared to the peak in the 70s.

Countries Most Affected By Terrorism Incidents 2016


Wikipedia has a list of the terrorist incidents that happened during the current year. Based on this information, the countries most affected by the number of terror incidents are: