Sign in

Libre University uses your GitHub account. Signing in is only needed to sit a final test, so the score is kept on your profile.

What logging in actually is

A web server has no memory of you, so being logged in is a fiction it rebuilds from scratch on every request.

This course assumes you can read and write JavaScript: functions, objects, arrays, async and await, and enough of the standard library to run a script. The Programming subject on this site covers all of it. Nothing else is assumed, and in particular no cryptography, which this lesson introduces from nothing. Every code sample here is modern JavaScript that runs unchanged in a browser console, in Bun, or in a recent Node, using the global crypto object that all three provide.

Three questions that get called one word

"Log in" is a single phrase covering three separate questions, and almost every serious authentication bug is a confusion between them.

Identification is the claim. A visitor types [email protected] into a box, and that is a claim about who they are, no more trustworthy than a name written on a form. Identification costs nothing to fake and proves nothing, which is why an email address in a request body is data, not evidence.

Authentication is the evidence. The visitor supplies something that distinguishes the real owner of that account from everyone else: a password only they should know, a code from a device only they should hold, a signature only their hardware key can produce. Authentication is where cryptography lives, and it answers exactly one question: is the claim true?

Authorization is the permission. Ada is genuinely Ada, and now the server has to decide whether Ada may delete invoice 4192. Authentication says nothing about this. A perfectly authenticated user asking for somebody else's data is the single commonest real breach on the web, and the reason it happens is that the code checked the first two questions and assumed the third.

Keep the words apart and a whole class of bug becomes visible. GET /invoices/4192 from a signed-in browser is an authenticated request; whether it is an authorized one depends on a database check that nothing about the session can supply. The last lesson of this course is about that check. The eight in between are about getting a trustworthy answer to the middle question and keeping it alive across a conversation with a protocol that forgets.

HTTP forgets between requests

HTTP is stateless by design. Each request arrives with its method, its path and its headers, and the server answers it and lets go. Two requests from the same browser, one second apart, are unrelated events as far as the protocol is concerned. There is no connection identity to lean on: a page load fires dozens of requests across several connections, connections are pooled and reused across different users behind a proxy, and an IP address may be shared by a whole office or change mid-session on a phone.

So there is nothing in the transport that says "this is the same person as before". Which means being logged in cannot be a state the server remembers about a visitor. It has to be a claim the browser presents, freshly, on every request, and that the server can check without having seen the previous request.

The whole of this course follows from that sentence. If the browser must present something every time, that something is a secret, and every design question becomes: what is the secret, who can obtain it, how long is it good for, and what happens when it leaks. Passwords, session cookies, JWTs, TOTP codes and OAuth access tokens are all answers to the same question, differing in where the secret lives and what it costs to steal.

One consequence deserves saying out loud now, because it is the source of endless confusion later. A secret the browser sends automatically, like a cookie, is convenient and is sent even when the request was not the user's idea, which is the CSRF problem. A secret the browser has to be told to send, like an Authorization header, is not sent automatically and so needs to be readable by JavaScript, which makes it stealable by injected script, which is the XSS problem. Neither choice escapes both. A later lesson is entirely about living with that trade.

Threat modelling, briefly and concretely

Security work with no named adversary drifts into ritual: rules followed because they are rules, and effort spent where nothing is at risk. Before choosing a mechanism, write down three things.

Who. For an ordinary web application the realistic list is short. There is the opportunist running credential stuffing, replaying username and password pairs from other sites' breaches at a rate of thousands per minute, who does not care about you specifically. There is the phisher, who wants one particular person's account and will send them a convincing email. There is the person who steals your database, through SQL injection or a leaked backup, and now holds every password hash offline. And there is the attacker who gets script running on your origin, through a stored comment or a compromised dependency, and can then act as any visitor who loads that page.

What they already have. This is the question people skip. The stuffing attacker already has the user's password, because it was reused. Password hashing does nothing against them; rate limiting and a second factor do. The phisher will obtain a password and a TOTP code, both, because the user will type them into the wrong site. Only origin-bound credentials stop them. The database thief has your hashes and your salts and your source code, so a scheme whose strength depends on the salt being secret has no strength. Match the defence to the adversary or you will harden the wrong thing.

What losing looks like. A forum account and a payroll account justify different friction. Requiring a hardware key to read a recipe site loses you the reader; not requiring re-authentication before a bank transfer loses the customer's money. Security that users route around is worse than the weaker scheme they would have used honestly, and this is a real engineering constraint rather than an excuse.

Example. Your service is breached and an attacker walks off with the users table: emails, password hashes, salts, and the session_id column. Which of your defences still stand, and which have already failed?

Password hashing still stands, but only as a delay: the attacker now guesses offline at whatever rate their hardware allows, and the next lesson is about making that rate small. Salts have done their job already, since they stopped one pass of guessing from cracking every account at once, and it does not matter that the attacker can read them. Rate limiting has failed completely, because it only ever applied to guesses arriving over the network. If the sessions table stores raw session identifiers rather than hashes of them, every live session is stolen outright with no guessing at all, and that is why the sessions lesson insists on hashing them too.

Now you. A user's password is correcthorse, which also appears in a corpus of two hundred million passwords from other sites' breaches. An attacker tries it against your login form. Which of your defences, if any, is doing anything?

Answer

Not password hashing, which only slows guessing against a stolen file and is irrelevant when the attacker guesses right on the first try over the network. What remains is rate limiting, which caps how many accounts per hour the attacker can walk through; a check against a breach corpus at the moment the password was chosen, which would have refused it; and a second factor, which is the only thing that still holds once the password is known. Against credential stuffing, the hash is not part of the defence.

Randomness an attacker cannot predict

Nearly every mechanism in this course mints a secret: a session identifier, a reset token, a CSRF token, a TOTP secret, an OAuth state. All of them need randomness that an attacker cannot reproduce, and JavaScript offers two sources that look interchangeable and are not.

Math.random is a fast pseudorandom generator built for simulations and animation. It is seeded from an unspecified source, it has a small internal state, and its output is a deterministic function of that state. V8 uses xorshift128+, with 128 bits of state and no cryptographic mixing between the state and the output, so an attacker who sees a handful of outputs can solve for the state and then print every number the generator will ever produce, forwards and backwards. If session identifiers come from Math.random, an attacker signs up for an account, reads their own identifier, and derives yours.

crypto.getRandomValues fills a typed array from the operating system's cryptographic generator, which is seeded from hardware entropy and designed so that observing output tells you nothing about the state. It is the only acceptable source for anything secret.

const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const token = bytes.toBase64({ alphabet: "base64url" });

Sixteen bytes is 128 bits, and 128 bits is the number to remember. The reason is worth deriving rather than accepting. A guessing attacker who can try 1012 candidates per second, which is far beyond what any network-facing service would permit and generous even for offline work, needs 2128/1012 seconds to exhaust the space, which is 1.1×1019 years. Halve the exponent and the picture changes completely: 264 at the same rate takes 0.59 years, so a 64-bit token is a machine-months problem, not an impossibility. That gap between "seven months" and "longer than the universe" is why the standard advice is 128 bits and why it is not negotiable downward for a bearer secret.

Encoding matters less than people expect but is worth getting right. Sixteen random bytes encoded as base64url become 22 characters, because each character carries 6 bits and 128/6=22. Those 22 characters carry exactly 128 bits of entropy, no more: encoding never adds any. Writing the same bytes as 32 hexadecimal characters carries the same 128 bits in a longer string.

Example. A colleague generates password reset tokens as six characters drawn from a 32-character alphabet, and argues that a billion possibilities is plenty. Your rate limiter allows 10,000 attempts per second against the reset endpoint before it trips. How long does an attacker need?

The space is 326=1.07×109, which is exactly 30 bits, since 32=25 and 6×5=30. At 10,000 guesses per second, exhausting it takes 1.07×109/104=1.07×105 seconds, which is 29.8 hours, and an attacker expects to succeed in half that, about 14.9 hours. Worse, tokens are usually valid for many accounts at once, so the attacker is not hunting one particular token but any live one, and with a thousand pending resets the search shortens by a factor of a thousand to under a minute. Thirty bits is not a billion possibilities in any useful sense.

Now you. How many random bytes must you generate for a token with at least 128 bits of entropy if you encode them in hexadecimal, and how long is the resulting string?

Answer

Sixteen bytes, exactly as before: the entropy is in the bytes, not in the encoding. Hexadecimal spends one character per 4 bits, so 16 bytes become 32 characters. Those 32 characters and the 22-character base64url string carry identical strength. Choosing hexadecimal costs 10 characters of URL and buys easier reading in a log.

Hash, MAC, encrypt

Three operations get called "encryption" in casual speech, and they do different jobs. Choosing the wrong one is not a subtle weakness but a structural failure.

A hash is one way. SHA-256 takes any input and gives 32 bytes, quickly, with no key and no way back. It is the right tool when you need to recognise a value later without storing it: comparing a session token you received against a digest in the database, or checking that a file has not changed. What it is not is a way to hide a password, because "no way back" only holds when the input is unpredictable. There are far fewer plausible passwords than there are 32-byte digests, so an attacker hashes candidates and compares. The next lesson is entirely about that.

A MAC, message authentication code, is a keyed hash: HMAC-SHA-256 takes a key and a message and produces a tag that only a holder of the key can compute. It answers "did someone with the key produce this, and has it been altered", and it is what signs a cookie, a JWT with a symmetric algorithm, or a TOTP code. A MAC does not hide anything. The message it protects stays readable, which surprises people who sign a token containing a user's role and then treat that role as private.

Encryption hides content. Modern authenticated encryption such as AES-GCM produces ciphertext plus a tag, so it both conceals and detects tampering. In web authentication you need it less often than you would think, and reaching for it is usually a sign of a design that stores something in the browser that should have stayed on the server.

The distinction that saves the most trouble: signing proves origin, encrypting hides content, and hashing does neither in a reversible way. A signed session token is readable by anyone holding it. Put nothing in it you would mind the user reading.

Comparing secrets without leaking them

Once you hold a secret and a candidate, you compare them, and the obvious comparison leaks.

if (token === stored) { /* ... */ }

String equality in every runtime stops at the first differing character. So a comparison that fails at position 0 returns faster than one that fails at position 20, and the difference is measurable. An attacker who can time your endpoint sends a token beginning a, then b, and so on, keeping whichever prefix takes fractionally longer, and recovers the secret one character at a time. Instead of 3222 guesses they need 32×22.

The fix is to look at every byte regardless.

function equalBytes(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
  return diff === 0;
}

The XOR of equal bytes is zero, so accumulating with |= leaves diff at zero only when every byte matched, and the loop runs the same number of times either way. The length check is a real leak, but of length only, and lengths here are fixed and public.

In practice the tidier defence is to make the comparison unnecessary. Hash the incoming token with SHA-256, look the digest up by index, and let the database do an equality test on a value that is not secret in the useful sense: an attacker who learns a digest still cannot present the token that produces it. That pattern appears in the sessions lesson and again in the account lifecycle lesson, and it removes both the timing problem and the problem of storing raw secrets.

Timing leaks are also easy to overstate. Over the public internet, jitter is far larger than a few nanoseconds of string comparison, and a remote attack of this kind needs a great many samples. But the fix costs four lines, the attacker may be on your network, and "probably too noisy" is a poor foundation. Write the loop.

Example. This handler verifies a webhook signature. What leaks, and what should it be?

const expected = await hmacHex(secret, body);
if (request.headers.get("x-signature") !== expected) return new Response("no", { status: 401 });

The !== compares two hex strings and stops at the first difference, so the response time grows with the length of the correct prefix an attacker supplies. Since the attacker controls the header and can send unlimited attempts, they can walk the signature out one hex digit at a time, 16 tries per position. Decode both sides to bytes and compare with the constant-time loop above, or hash both sides with SHA-256 and compare the digests, which is the same trick as with session tokens.

Now you. Somebody proposes fixing the timing leak by adding a random delay of up to 50 milliseconds to every response. Does that work?

Answer

Not reliably. Random noise added to a signal does not remove the signal, it just means the attacker needs more samples: averaging over enough requests recovers the underlying difference, and 50 milliseconds of uniform noise against a consistent nanosecond-scale bias is beaten by taking many measurements per candidate. It also makes every legitimate response slower. Constant-time comparison removes the signal rather than burying it, and costs nothing.

Encoding is not encryption

Base64, hexadecimal, URL encoding and JSON are ways of writing bytes down. None involves a key, all are reversible by anyone, and treating any of them as protection is a recurring and embarrassing failure.

const cookie = btoa(JSON.stringify({ user: "ada", admin: false }));
// eyJ1c2VyIjoiYWRhIiwiYWRtaW4iOmZhbHNlfQ==

That string looks opaque and is not. Any user decodes it, changes false to true, re-encodes, and sends it back. If the server trusts the contents, the site has no authentication at all. The failure repeats at a larger scale with JWTs, whose payload is base64url text that people routinely believe to be hidden.

The rule: if the browser holds a value and the server acts on its contents, the value must carry a MAC computed with a key the browser does not have, or its contents must live on the server with only an unguessable identifier in the browser. Encoding is for transport, and it protects nothing.

What has to happen next

Everything above is scaffolding. The server needs a secret from the user on every request, it can generate unguessable values, it can recognise them again without storing them in the clear, and it can compare them without leaking. What it does not yet have is a first secret, and the first secret in almost every system is one the user chose themselves.

That is the hardest possible input to work with. A user-chosen password is short, drawn from a distribution an attacker can model well, reused across a dozen other sites, and it has to survive the day your database is copied. Making a chosen password hard to reverse is a different problem from anything in this lesson, and it is where the course goes next.