Authentication: sessions, JWT, two-step and passkeys

How a server proves who is calling: authentication versus authorization, how a JWT is built, signed and checked, where a browser keeps a session and how it survives XSS and CSRF, how a session is ended early, how passwords are stored and guessing is stopped, and how authenticator codes and passkeys work inside - step by step, with what {{brand}} chose and why.

In short

Authentication answers one question: who is making this request? In {{brand}} you prove it with a password and, if you turned it on, a second step - an authenticator code or a passkey. The server then gives your browser a signed token, a JWT, inside an HttpOnly cookie that page scripts cannot read. Every later request carries that cookie; the server checks the signature, checks that the session behind it is still alive, and reads your roles and permissions fresh from the database.

After this lesson you can 1. Explain how a JWT is built, signed and checked. 2. Choose where a browser keeps a session and defend it against XSS and CSRF. 3. End a session early and store passwords safely. 4. Walk through TOTP and passkeys step by step.

Words you need

Authentication and authorization are two questions

The two words are often mixed up, and the difference is a classic first interview question. Authentication proves identity once, at sign-in, and is then re-checked cheaply on every request. Authorization decides, on every request and for every record, whether that identity may act. This lesson is about the first; the access lesson covers the second.

One sign-in, from password to cookie

A passkey replaces steps 1 to 3 With a passkey, the device proves who you are with a key it holds and your fingerprint, face or PIN. That is two factors at once - something you have and something you are or know - so the sign-in goes straight to step 4.

Inside a JWT

A JWT (RFC 7519) is three pieces of text joined by dots: a header, a payload of claims and a signature. The first two are only base64url-encoded JSON, so anyone holding the token can read them. Only the signature needs the key.

Signed is not secret A JWT is signed, not encrypted. Never put a password, a personal detail or anything secret in the payload; a display name is tolerable only when nothing trusts it for decisions. An encrypted form, JWE, exists, but it is rarely needed when the token only travels between your own browser and your own API over HTTPS.

How a JWT is signed: HS256, RS256, ES256

The header's alg says how the signature was made. There are two families. Symmetric algorithms (HMAC, the HS family) use one shared secret that both signs and checks. Asymmetric algorithms (RS, PS, ES and EdDSA) sign with a private key and check with a public key, so a service that only checks tokens never holds anything that can make one.

Key ids make rotation painless Put a kid (key id) in the header. The server then accepts the current key and the previous ones by id: it can start signing with a new key today and let tokens signed with the old one run out naturally, so a key rotation signs nobody out.

How a server checks a JWT, step by step

A browser session usually rides in a cookie instead of a header. The server sets the cookie once at sign-in, and a small hook copies it into the token slot when no Authorization header is present. A header, if there is one, wins.

The options in the market

What {{brand}} chose, and why

{{brand}} is one API serving its own web app. The same service issues and checks every session token, so an HS256 signature with a strong key from configuration is enough - and a key that never leaves the server is easier to protect than a published key set. The token rides in an HttpOnly cookie, because then the most common web attack, XSS, cannot steal it.

The price is a small, cached session check on every request. Signing out and ending a device take effect at once. {{brand}} accepted that in exchange for simplicity and a list of signed-in devices that people can see and end themselves.

When another choice is right Many services checking tokens, or tokens handed to third parties: RS256 or ES256 with a published key set. A mobile app: tokens in the platform's secure storage, with OAuth 2.0, PKCE and refresh tokens. A server-rendered site on one domain: a plain session id cookie is simpler than any JWT.

Cookie settings that matter

A SameSite=Strict cookie travels between same-site hosts, but a call from app.example.com to api.example.com is still cross-origin. CORS is the browser rule that lets a page read a response from another origin only if that server allows it. For cookies the API must name the exact origin (never *) and send Access-Control-Allow-Credentials: true.

XSS and CSRF: the two attacks a session cookie must survive

XSS is an attacker's script running inside your page - through an unescaped comment, a bad dependency or a crafted link. CSRF is another website making your browser send a request to yours with your cookie attached. HttpOnly answers the first; SameSite, origin checks and a custom header answer the second.

In practice, layer them: SameSite, an origin check and a custom header. Feed the origin check and CORS from one list of allowed origins so they cannot disagree. Requests that authenticate with an explicit Authorization header are not ambient, because browsers never add that header on their own.

The main defence against XSS is output encoding: show user text as text, so < becomes &lt; and cannot start a tag. Frameworks such as React do this for you. The risk returns when you must show rich text - bold, lists, links - because that is HTML by nature. Setting innerHTML (React's dangerouslySetInnerHTML) with raw HTML runs any script inside it, for example an img tag with an onerror handler. The answer is a sanitizer.

DOMPurify parses the string with the browser's own HTML parser into an inert document, where scripts never run and images never load. It then walks every node and attribute and deletes whatever is not on its allow-list: script elements, event attributes such as onclick, javascript: addresses and markup that would turn dangerous when parsed a second time (mutation XSS). Last, it writes the clean tree back as a string. Because it uses the real parser, it reads ambiguous markup the way the browser will. You can narrow the allow-list with ALLOWED_TAGS and ALLOWED_ATTR.

{{brand}} sanitizes rich text twice, in two places: the server keeps only an allow-list of tags when text is saved, and the browser runs DOMPurify again before showing it. Stored text can be older than today's rules, imported, or written by another client, so no single check is trusted. DOMPurify was chosen because it is the most widely reviewed browser sanitizer and works with the browser's own parser; the trade-off is a library to keep updated, since bypasses have been found and fixed in sanitizers before.

Sanitize last, and never edit the result Clean the HTML at the moment you hand it to the page and insert exactly what came back. Joining strings after cleaning, or decoding after cleaning, can bring a script back. Keep DOMPurify updated, and add a Content-Security-Policy as the net underneath.

HttpOnly does not make XSS harmless An injected script cannot read the cookie, but it can still send requests from your page, and the browser attaches the cookie. Escape all output, set a Content-Security-Policy, and keep dependencies few and reviewed. HttpOnly limits the damage to the life of the page; it does not remove it.

Ending a session before its token expires

A signed token stays valid until exp: the signature cannot know that you signed out, reset your password or were deactivated. Every system that uses JWTs must add a server-side check and decide what it costs. These are the four tools.

Why the token carries no roles If the token said role: admin, removing that role would only count when the token expired. {{brand}} puts who you are in the token and reads what you may do from the database on every request, so a removed permission counts on the very next click.

Short access token plus refresh token

The other classic answer to revocation is a very short-lived access token paired with a refresh token. It is the pattern behind OAuth 2.0 for mobile and single-page apps (with PKCE protecting the first hand-over), and interviewers ask about it constantly.

The price is a refresh endpoint, a token store and the rotation logic.

The session remembers how it signed in

An account may have a passkey while this particular browser signed in with the password alone. A rule such as a company requiring two-step sign-in must therefore ask about the session in use, not only the account. OpenID Connect carries this as the amr claim (authentication methods such as pwd, otp or hwk); a system can record with each session whether a second step was used.

Storing passwords: slow, salted, versioned hashes

A server must be able to check a password without being able to read it, so it stores a one-way hash. Ordinary hashes such as SHA-256 are far too fast: one graphics card tries billions per second. Password hashes are deliberately slow and add a random salt per password, so two people with the same password get different hashes and precomputed tables are useless.

Breached-password checks with k-anonymity

Attackers start with the passwords that leaked from other sites. Have I Been Pwned publishes hundreds of millions of them through the Pwned Passwords range API, which you can ask without sending the password - or even its full hash. The trick is called k-anonymity: the service learns only a bucket shared by hundreds of known hashes and countless possible passwords.

{{brand}} can run this check wherever a password is set - at sign-up and at a password reset - as an extra rule next to the length rule, when the deployment enables it. An outage of the outside service never blocks a sign-up.

Account enumeration and password guessing

Account enumeration is learning which email addresses have accounts - the first step of a targeted attack. It leaks through words (no account with this email), through timing (an unknown address answers faster because no hash runs) and through side doors such as sign-up and password reset.

Authenticator codes: how TOTP works (RFC 6238)

TOTP is how Google Authenticator, Microsoft Authenticator, 1Password and the rest make a new six-digit code every 30 seconds. The phone and the server share one secret; each computes the code from that secret and the current time, so no network is needed. TOTP is HOTP (RFC 4226) with the counter replaced by the clock.

A QR code carries the otpauth address from the screen to the phone, so nobody types 32 characters. Inside, it is a grid of dark and light modules. The text becomes bytes, the bytes are split into blocks, and Reed-Solomon error correction is added, so the code scans even when part of it is scuffed. There are four levels: L, M, Q and H recover about 7, 15, 25 and 30 percent. Three big corner squares, the finder patterns, tell the camera where the code is and how it is turned, and a mask is chosen that avoids long runs of one colour. The version (1 to 40, from 21 by 21 to 177 by 177 modules) is simply the grid size: the smallest one that holds the text at the chosen level.

qrcode-generator was chosen because it is small, has no dependencies and gives you the grid itself: you decide how to draw it, so the page can build its own SVG instead of inserting markup a library produced. Heavier alternatives such as the qrcode package render to canvas, terminal or image for you, and qr-code-styling adds logos and rounded dots - useful for marketing, but decoration lowers scan reliability. Generating on the server (for example with QRCoder in .NET) also works, but then an image carrying the secret travels over the network. {{brand}} draws the code in the browser from the address its own server sent. The trade-off: you write the drawing code yourself, and you must handle a text too long for any version (show the typed key instead).

Never draw the secret with someone else's QR service Some tutorials build the picture from a web address such as qr.example/?data=otpauth://... The secret then travels to a third party and lands in their logs and caches. Draw the code locally, keep it dark on light in every theme (an inverted code often fails to scan), and show the text key beside it for people who cannot scan.

TOTP can still be phished A fake sign-in page can ask for your code and use it within the same 30 seconds. TOTP defeats leaked and reused passwords, not real-time phishing. Passkeys defeat both.

Passkeys and WebAuthn, step by step

A passkey is a key pair that your device makes for one website. The private key never leaves the device or your synced password manager; the site stores only the public key. To sign in, the site sends a random challenge, the device asks for your fingerprint, face or PIN, and signs it. The browser standard is WebAuthn (W3C); with CTAP for external security keys, the family is called FIDO2.

Why passkeys cannot be phished The browser, not the person, writes the origin into clientDataJSON, and the device signs over the RP ID's hash. A look-alike site at examp1e.com cannot get a signature for example.com, and there is no code to type into the wrong page. A database leak gives attackers only public keys.

{{brand}} uses the passkey support built into ASP.NET Core Identity in .NET 10. Whatever library you use, enforce the same rules: bind each ceremony to one purpose and one person, accept it once and briefly, require user verification, and accept only your own origins.

ASP.NET Core Identity: what the framework gives you

ASP.NET Core Identity is .NET's membership library: users, password hashing, lockout, tokens for email links, two-factor plumbing and - since .NET 10 - passkeys, stored through EF Core. It is not a sign-in server: to issue OpenID Connect tokens to other apps you add one, such as Duende IdentityServer or OpenIddict, or use a provider such as Entra ID.

{{brand}} builds on ASP.NET Core Identity for users, link tokens and passkeys, and signs its own session token, so one token format serves the browser and other clients. Authenticator codes use a small RFC 6238 implementation.

Try it yourself: sign and break a JWT, then make a TOTP

All you need is Node.js 20 or newer. In ten minutes you will see why the signature matters, why signed is not secret, and that a six-digit code is plain arithmetic.

Common mistakes

Interview questions

Related