Accounts, sessions and sign-in for TypeScript apps, done safely, inside your own server.
Starts from zero: what signing in really means, how cookies, sessions and password hashing work, and how accounts get attacked. Then builds a notes app with Better Auth on a Hono server, with email and password, verification, resets, magic links, GitHub and Google sign-in, roles, two-factor sign-in and an admin, and ends with testing, hardening and running it in production.
What you’ll be able to do
Explain authentication, authorization, cookies, sessions, tokens and password hashing in plain words.
Set up Better Auth with a database and a Hono server, and call it from a browser client.
Build sign-up, sign-in, email verification, password resets and magic links that don't reveal who has an account.
Add GitHub and Google sign-in, link accounts safely, and protect routes with sessions, ownership checks and roles.
Extend it with plugins and hooks, rate limits and bot checks, and test every flow.
Harden cookies, origins and secrets for production, and respond when something goes wrong.
Modules
Who you are, and what you may do· 8 slides
HTTP forgets you, cookies remind it· 9 slides
Sessions and tokens· 9 slides
Storing passwords safely· 8 slides
How accounts get attacked· 9 slides
Installing Better Auth· 8 slides
Serving it from a Hono server· 9 slides
The client in the browser· 8 slides
Email and password, properly· 8 slides
Emailed links: verification, resets and magic links· 9 slides
Sessions in depth· 8 slides
Social sign-in with OAuth, and linking accounts· 9 slides
Protecting routes, and roles· 8 slides
Plugins: two-step sign-in, passkeys and teams· 8 slides
Hooks and fields of your own· 8 slides
Rate limits, bots and breached passwords· 8 slides
Testing auth flows· 8 slides
Security hardening· 9 slides
Running it in production: domains, audits, incidents· 9 slides
Mastery challenge· 10 slides
Interactive parts (exercises, code to run, walkthroughs, flashcards, checklists) are drawn still and marked “Interactive · disabled in the PDF”. Do them in the app; the answers are at the end.
Module 1: Who you are, and what you may do
Better Auth1 / 170
Module 1 · Who you are, and what you may do
An app with private things has to know who is asking
Picture a small notes app. Anyone can open its address in a browser, but each person should see only their own notes. To do that, the app has to answer two questions every time someone asks for something: who is this? and are they allowed to do this?
This course follows that notes app from its first sign-up page to a production system with email links, "Sign in with GitHub", two-step sign-in and an admin who manages users. Every idea lands on it.
Ana signs up with her email and a password.
She confirms her email by clicking a link.
She writes notes; only she can read them.
She forgets her password and chooses a new one.
Later she signs in with her GitHub account instead.
An admin can see users, and block one who misbehaves.
Better Auth2 / 170
Module 1 · Who you are, and what you may do
Authentication is who you are; authorization is what you may do
Authentication
Answers "who is this?"
Happens when you sign in
Proved with a password, a link, a code
Short name: authn
Authorization
Answers "may they do this?"
Happens on every action
Decided by rules: owner, role, plan
Short name: authz
A hotel is a good picture. At the front desk you show your passport: that is authentication. The key card you get opens your room and the gym, but not other rooms: that is authorization. The desk checks you once; the doors check your card every time.
QuizInteractive · disabled in the PDF
Ana is signed in and tries to open a note that belongs to Ben. The app refuses. Which check refused her?
Ana is signed in and tries to open a note that belongs to Ben. The app refuses. Which check refused her?
Better Auth3 / 170
Module 1 · Who you are, and what you may do
Credentials prove who you are
A credential is anything you present to prove you are who you say: a password, a code from your phone, a fingerprint. Security people group them into three kinds, called factors.
Factor
Examples
Weakness
Something you know
Password, PIN
Can be guessed, reused, or tricked out of you
Something you have
Phone app codes, a security key
Can be lost or stolen
Something you are
Fingerprint, face
Cannot be changed if copied
Credential
Something presented to prove identity: a password, a code, a key.
Multi-factor authentication (MFA)
Asking for two or more different factors, such as a password and a phone code.
QuizInteractive · disabled in the PDF
Which of these is really two-factor sign-in?
Which of these is really two-factor sign-in?
Better Auth4 / 170
Module 1 · Who you are, and what you may do
One person, one user, several ways in
Apps keep a user for each person: an id, an email, a name. Separately they keep the ways that person can sign in. Ana might use a password today and her GitHub account tomorrow, and both should open the same notes.
user: Anaana@example.com
account: passworda scrambled copy of it
account: GitHubGitHub's id for Ana
sessionher laptop
sessionher phone
Better Auth calls each way in an account. We meet these tables properly in Module 6.
User
The record of one person in your app.
Account
One way that user can sign in: a password, GitHub, Google.
Session
One signed-in browser or device. Module 3 explains them.
Better Auth5 / 170
Module 1 · Who you are, and what you may do
The journeys every app with accounts needs
Journey
What it means
Sign up
Make a new user
Sign in
Prove who you are and get a session
Sign out
End the session on this device
Verify email
Prove you can read the inbox you typed
Reset password
Choose a new password through an emailed link
Social sign-in
Let GitHub or Google vouch for you
Put in orderInteractive · disabled in the PDF
Put Ana's first visit to the notes app in order.
1.She clicks the link in the email
2.She writes her first note
3.She fills in the sign-up form with her email and a password
4.The app saves a new user and sends her a confirmation email
5.The app marks her email as confirmed and signs her in
Better Auth6 / 170
Module 1 · Who you are, and what you may do
Why use a library instead of writing it yourself
Each journey looks simple and hides many traps: storing passwords safely, links that can only be used once, cookies with the right settings, limits on guessing, not revealing who has an account. One mistake can expose every user.
Better Auth is an open-source TypeScript library that does this work inside your own server. It is not a separate hosted service: your users live in your own database, and you can read every line it runs.
Your pagessign-up form, notes list
Your serveryour routes: notes, settings
Better Authpasswords, sessions, links, OAuth
Your databaseusers, sessions, notes
The version taught. This course teaches Better Auth 1.7. Options and names have changed between versions, so when you read older guides, check their version first.
Better Auth7 / 170
Module 1 · Who you are, and what you may do
Checks that matter happen on the server
Three places take part: the browser (or a phone app), which shows pages; the server, a program on a computer you control that answers requests; and the database, where the server keeps data. The browser belongs to the visitor, so they can change anything in it.
QuizInteractive · disabled in the PDF
The notes page hides the "Delete" button on notes that are not yours. Is that enough to stop someone deleting other people's notes?
The notes page hides the "Delete" button on notes that are not yours. Is that enough to stop someone deleting other people's notes?
Better Auth8 / 170
Module 1 · Who you are, and what you may do
Recap: who and what
Authentication proves who someone is; authorization decides what they may do.
Credentials come in three factors: know, have, are. MFA mixes different factors.
A user is a person; an account is one way in; a session is one signed-in device.
Every app with accounts needs sign-up, sign-in, sign-out, email checks, resets and often social sign-in.
Better Auth is a library inside your server that keeps its data in your database.
The browser is the visitor's: real checks happen on the server.
FlashcardsInteractive · disabled in the PDF
Authentication
Proving who you are. "Who is this?"
Authorization
Deciding what you may do. "May they do this?"
Credential
Something presented to prove identity.
The three factors
Something you know, have, and are.
User vs account
The person vs one way that person signs in.
Where must permission checks run?
On the server, which the visitor cannot change.
Module 2: HTTP forgets you, cookies remind it
Better Auth9 / 170
Module 2 · HTTP forgets you, cookies remind it
The web is requests and responses
When you open a page, your browser sends a request to a server, and the server sends back a response. The rules for that conversation are called HTTP. HTTPS is the same conversation, encrypted so nobody in between can read or change it.
Method
What the request wants: GET reads, POST creates or acts, DELETE removes.
Path
Which thing on the server: /notes, /notes/42.
Status code
A number in the response saying how it went: 200 fine, 404 not found.
Header
A labelled line of extra information on a request or response.
The top half is the request, the bottom the response. Each Name: value line is a header.
Status
Means
In auth
200
OK
Signed in, here is your data
302
Go somewhere else
Sent on to GitHub, or back from it
401
Not authenticated
Sign in first
403
Forbidden
Signed in, but not allowed
404
Not found
Also used to hide what is not yours
429
Too many requests
Slow down: a rate limit
Better Auth11 / 170
Module 2 · HTTP forgets you, cookies remind it
Each request starts from nothing
HTTP is stateless: the server treats every request as if it had never seen you before. Ana can sign in with one request, and the very next request, by itself, says nothing about who sent it.
So after Ana proves who she is once, her browser needs something to send with every later request that says "this is still Ana". That something is almost always a cookie.
QuizInteractive · disabled in the PDF
Ana signs in, then clicks "My notes". Without cookies or tokens, what does the server know about the second request?
Ana signs in, then clicks "My notes". Without cookies or tokens, what does the server know about the second request?
Better Auth12 / 170
Module 2 · HTTP forgets you, cookies remind it
A cookie is a value the browser sends back for you
A cookie is a small named value that a server asks the browser to keep. The server sets it with a Set-Cookie header in a response. From then on, the browser adds a Cookie header with it to every request to that server, on its own.
Watch out. Whoever holds that cookie value is Ana as far as the server is concerned. Treat a session cookie like a password: never print it, never put it in a link, never log it.
One header, wrapped for the slide. Everything after the first ; is a setting, called an attribute.
Attribute
What it does
HttpOnly
Page scripts can't read the cookie, so injected code can't steal it
Secure
Sent only over HTTPS, never over plain HTTP
SameSite
Whether other sites can make your browser send it (next slide)
Max-Age / Expires
How long the browser keeps it; without either, until it closes
Path
Which paths it is sent to; / means the whole site
Domain
Lets subdomains receive it too; leave it out to keep it on one host
QuizInteractive · disabled in the PDF
An attacker manages to run their script on the notes page. Which setting stops that script reading the session cookie?
An attacker manages to run their script on the notes page. Which setting stops that script reading the session cookie?
Better Auth14 / 170
Module 2 · HTTP forgets you, cookies remind it
An origin is scheme, host and port
Browsers keep sites apart by origin: the scheme (https), the host (notes.example.com) and the port (443, the HTTPS default). Two addresses have the same origin only if all three match. A site is looser: every subdomain of example.com is the same site.
Compared with https://notes.example.com
Same origin?
Same site?
https://notes.example.com/settings
Yes
Yes
http://notes.example.com
No: scheme
No: scheme
https://api.example.com
No: host
Yes
https://notes.example.com:8443
No: port
Yes
https://evil.example.net
No
No
Note. Origins come back often: Better Auth checks the origin of requests, and only redirects to origins you trust. Module 19 deals with apps spread over several subdomains.
Better Auth15 / 170
Module 2 · HTTP forgets you, cookies remind it
SameSite decides what other sites can trigger
Strict: sent only when you are already on the same site. Even following a link from another site, such as your webmail, arrives without it.
Lax: also sent when you follow a link to the site (a top-level GET), but not with forms or scripts that other sites send. The usual choice for sessions.
None: always sent, even from other sites. Requires Secure. Only for setups that truly need it.
QuizInteractive · disabled in the PDF
Ana is signed in to the notes app (session cookie SameSite=Lax). She visits evil.example.net, whose page silently submits a form that POSTs to https://notes.example.com/notes/delete-all. Is her cookie sent?
Ana is signed in to the notes app (session cookie SameSite=Lax). She visits evil.example.net, whose page silently submits a form that POSTs to https://notes.example.com/notes/delete-all. Is her cookie sent?
Better Auth16 / 170
Module 2 · HTTP forgets you, cookies remind it
See it for yourself
Terminal
1# -i prints the response headers as well as the body2curl-ihttps://example.com/
curl is a command-line tool that sends HTTP requests. Most computers have it.
Try itInteractive · disabled in the PDF
Better Auth17 / 170
Module 2 · HTTP forgets you, cookies remind it
Recap: HTTP and cookies
A browser sends requests; a server sends responses, each with a status code and headers.
HTTP is stateless: nothing links one request to the next by itself.
A cookie is set with Set-Cookie and sent back with every request in Cookie.
HttpOnly, Secure, SameSite, Max-Age, Path and Domain control who sees and sends it.
An origin is scheme + host + port; a site includes all subdomains.
A session cookie is as valuable as a password.
FlashcardsInteractive · disabled in the PDF
Stateless
Each request stands alone; the server remembers nothing between them.
Set-Cookie vs Cookie
The server sets it in a response; the browser sends it back in requests.
HttpOnly
Page scripts can't read the cookie.
SameSite=Lax
Sent on links to the site, not on cross-site posts or scripts.
Origin
Scheme, host and port together.
401 vs 403
Not signed in vs signed in but not allowed.
Module 3: Sessions and tokens
Better Auth18 / 170
Module 3 · Sessions and tokens
A session is a coat-check ticket
At a coat check you hand over your coat and get a ticket with a number. The ticket means nothing by itself; the attendant looks the number up. A session works the same way: after sign-in, the server saves a row saying "token k7Qx… belongs to Ana until Friday" and gives the browser the token in a cookie.
Column
Example
Why
token
a long random string
What the cookie carries
userId
Ana's user id
Whose session it is
expiresAt
next Friday, 10:00
When it stops working
ipAddress, userAgent
where it was made
For a "your devices" page
Session
A record on the server that a signed-in device points at.
Session token
The random value that names the session. Whoever holds it is signed in.
Better Auth19 / 170
Module 3 · Sessions and tokens
The token must be long and random
If tokens were 1, 2, 3, anyone could change their cookie to 2 and become someone else. A session token is made by a cryptographic random generator and is long enough (32 characters or more) that guessing one is hopeless, even with billions of tries.
QuizInteractive · disabled in the PDF
An app makes session tokens from the user's id and the time they signed in. What is wrong?
An app makes session tokens from the user's id and the time they signed in. What is wrong?
Better Auth20 / 170
Module 3 · Sessions and tokens
A signature proves the server made it
A signature is a short code computed from a message and a secret only the server knows. Change one letter of the message and the signature no longer matches. Anyone can read a signed message, but only the holder of the secret can make a valid one.
Text
1cookie value: k7QxN2...pW9 . q3Jf8a...Lw0=2 ^ the token ^ its signature, made with the secret
Better Auth signs its session cookie this way and rejects any cookie whose signature does not match.
QuizInteractive · disabled in the PDF
A copy of the notes app's database leaks, session tables included. Better Auth signs its cookies. Can the thief paste a token from the database into a cookie and be signed in?
A copy of the notes app's database leaks, session tables included. Better Auth signs its cookies. Can the thief paste a token from the database into a cookie and be signed in?
Better Auth21 / 170
Module 3 · Sessions and tokens
A self-contained token carries its own proof
The other approach puts the facts inside the token: "this is Ana, valid until 10:15", plus a signature. The server checks the signature and believes the contents without looking anything up. The common format is the JWT (JSON Web Token), said "jot".
The middle part of a JWT, decoded. sub is who, exp is when it expires (in seconds since 1970). It is signed, not hidden: anyone can read it.
Watch out. Signed is not secret. Never put anything in a JWT that the holder should not read.
Better Auth22 / 170
Module 3 · Sessions and tokens
Looking it up costs a read; not looking it up costs control
Session in the database
One database read per check
Revoked instantly: delete the row
Small cookie
Can list a user's devices
Self-contained token
No lookup: fast, works anywhere
Valid until it expires, even after sign-out
Larger, and grows with what it carries
Kept short-lived to limit the damage
QuizInteractive · disabled in the PDF
Ana's laptop is stolen. She signs out everywhere from her phone. Which kind of credential on the laptop stops working immediately?
Ana's laptop is stolen. She signs out everywhere from her phone. Which kind of credential on the laptop stops working immediately?
Better Auth23 / 170
Module 3 · Sessions and tokens
Sessions expire, and use can extend them
A session has an end time. Many apps use a sliding window: each time you come back, the end moves later, so active people stay signed in and forgotten devices sign out on their own. Signing out deletes the session at once.
Better Auth does this by default: 7 days, extended at most once a day. Module 11 tunes it.
Better Auth24 / 170
Module 3 · Sessions and tokens
Where the browser keeps it matters
Kept in
Sent
Main risk
HttpOnly cookie
Automatically, by the browser
Other sites triggering requests (CSRF)
localStorage
Only when your code adds it
Any script on the page can read and steal it (XSS)
App memory or a keychain
In an Authorization header
For phone and desktop apps
QuizInteractive · disabled in the PDF
For a website, why do most auth libraries, Better Auth included, prefer an HttpOnly cookie to localStorage?
For a website, why do most auth libraries, Better Auth included, prefer an HttpOnly cookie to localStorage?
Better Auth25 / 170
Module 3 · Sessions and tokens
Better Auth's default: database sessions in a signed cookie
Signing in saves a session row and sets a signed, HttpOnly session cookie.
Each check verifies the signature, finds the row, and checks the end time.
An optional cookie cache also keeps a short-lived signed copy of the session in a cookie, skipping the lookup for a few minutes. Module 18 weighs that trade-off.
Plugins add bearer tokens for apps and JWTs for other services, when you need them.
Expert note. The cookie cache is the self-contained token idea used carefully: a short life (5 minutes by default) bounds how long a revoked session can keep working.
Better Auth26 / 170
Module 3 · Sessions and tokens
Recap: sessions and tokens
A session is a row on the server; the cookie holds its random token.
Tokens must be long and random, never built from guessable parts.
A signature made with a secret proves the server made a value; it does not hide it.
A JWT carries its own facts; it is fast to check but hard to revoke.
Sliding sessions keep active people signed in and forgotten devices out.
Websites keep session tokens in HttpOnly cookies.
FlashcardsInteractive · disabled in the PDF
Session token
A random value naming a session row. Holding it means being signed in.
Signature
A code made from a message and a secret, proving who made it.
JWT
A self-contained signed token: readable by anyone, checked without a lookup.
Why are database sessions easy to revoke?
Delete the row and the next check fails.
Sliding expiry
Each use pushes the end time later.
Main risk of tokens in localStorage
Any injected script can read and steal them.
Module 4: Storing passwords safely
Better Auth27 / 170
Module 4 · Storing passwords safely
Never keep the password itself
Databases leak: a stolen backup, a misconfigured server, an employee with too much access. If the notes app kept passwords as typed, a leak would hand out every password, and most people reuse theirs on other sites, including their email and bank.
The goal: the server must be able to check a password without being able to recover it. That is exactly what a hash gives.
QuizInteractive · disabled in the PDF
Why is a plain-text password leak worse for users than for the notes app itself?
Why is a plain-text password leak worse for users than for the notes app itself?
Better Auth28 / 170
Module 4 · Storing passwords safely
A hash is a one-way fingerprint
A hash function turns any input into a fixed-length string of characters. The same input always gives the same output, a tiny change gives a completely different one, and there is no way to run it backwards.
Text
1"correct horse" -> 9b71d224bd62f378...2"correct horsf" -> a3c5e0b1f4829d06...3"correct horse" -> 9b71d224bd62f378... (same as the first)
Illustrative, shortened outputs.
Better Auth29 / 170
Module 4 · Storing passwords safely
Hashing is not encryption
Encryption
Two-way: with the key, you get the original back
Steal the key and every password is revealed
Right for data you must read again, not for passwords
Hashing
One-way: nothing turns a hash back into the password
The server never needs the original again
The right tool for passwords
QuizInteractive · disabled in the PDF
A site emails you your old password when you click "Forgot password". What does that tell you?
A site emails you your old password when you click "Forgot password". What does that tell you?
Better Auth30 / 170
Module 4 · Storing passwords safely
A salt makes every hash unique
Plain hashing has a flaw: everyone with the password sunshine1 gets the same hash, and attackers keep huge precomputed lists of common passwords and their hashes. A salt fixes this: a random value made for each user and mixed in before hashing, then stored next to the hash.
User
Password
Salt
Stored hash
Ana
sunshine1
x9Tq…
4e1f…
Ben
sunshine1
Lm2c…
b70a…
QuizInteractive · disabled in the PDF
The salt is stored right next to the hash. Does it need to be secret to work?
The salt is stored right next to the hash. Does it need to be secret to work?
Better Auth31 / 170
Module 4 · Storing passwords safely
Password hashes are slow on purpose
General hashes like SHA-256 are built to be fast: a graphics card can try billions of guesses a second. Password hashes such as scrypt, Argon2 and bcrypt are deliberately slow and memory-hungry, so each guess costs real time. A tenth of a second per sign-in is nothing for a person and crippling for an attacker.
QuizInteractive · disabled in the PDF
An attacker with a leaked database tries 10 billion guesses per user. A fast hash allows 1 billion guesses a second; a slow one about 10. Roughly how long per user does each take?
An attacker with a leaked database tries 10 billion guesses per user. A fast hash allows 1 billion guesses a second; a slow one about 10. Roughly how long per user does each take?
In Better Auth. Better Auth hashes with scrypt by default and stores the salt together with the hash. You can supply your own hash and verify functions, for example to use Argon2 or to read hashes from an older system (Module 9).
Better Auth32 / 170
Module 4 · Storing passwords safely
Length beats complexity rules
Length is what makes guessing hard. Four random words beat P@ssw0rd!.
Refuse known-breached passwords: lists of millions of leaked passwords exist, and attackers try those first.
Allow every character, spaces included, and let password managers paste.
Cap the length at something generous: hashing a megabyte of text would let anyone slow your server down.
Don't force regular changes: people respond with Spring2024, Summer2024…
Better Auth option
Default
minPasswordLength
8
maxPasswordLength
128
Breached-password check
The haveIBeenPwned plugin (Module 16)
Better Auth33 / 170
Module 4 · Storing passwords safely
Checking a password without giving anything away
Put in orderInteractive · disabled in the PDF
Put the steps of a careful password check in order.
1.Answer the same way for a wrong password and an unknown email
2.Read their stored salt and hash
3.Compare the two hashes in constant time
4.Find the user by the email typed
5.Hash the typed password with the same salt and settings
Better Auth34 / 170
Module 4 · Storing passwords safely
Recap: passwords
Store a hash, never the password, and never something reversible.
A per-user random salt makes identical passwords hash differently.
Use a slow password hash: scrypt (Better Auth's default), Argon2 or bcrypt.
A pepper is an extra secret kept outside the database.
Prefer long passwords, refuse breached ones, cap the length.
Check in constant time and answer the same for unknown emails.
FlashcardsInteractive · disabled in the PDF
Hash
A one-way fingerprint of some input: same input, same output, no way back.
Salt
A random per-user value mixed into the hash, stored next to it.
Pepper
A secret mixed into every hash and kept outside the database.
Better Auth's default password hash
scrypt.
Why are password hashes slow?
So each guess costs an attacker real time.
Default password lengths in Better Auth
At least 8, at most 128 characters.
Module 5: How accounts get attacked
Better Auth35 / 170
Module 5 · How accounts get attacked
Attackers go after the weakest step
An attacker wants to act as someone else: read their notes, change their email, lock them out. They rarely break cryptography. They trick people, guess, reuse leaks, or ride on a session the victim already has. Knowing the common moves makes the defences in this course make sense.
Attack
In one line
Phishing
A fake page tricks you into typing your password
Credential stuffing
Email and password pairs leaked elsewhere, tried here
Brute force
Guess after guess against one account
Account enumeration
Finding out which emails have accounts
CSRF
Another site makes your browser send a request for you
XSS
The attacker's script runs on your page
Session theft
Someone copies your session and is you
Better Auth36 / 170
Module 5 · How accounts get attacked
Phishing: a convincing fake
An email says "Your notes will be deleted, sign in to keep them", with a link to notes-example.com-login.net. The page looks exactly like the real one. Whatever Ana types goes to the attacker. No server-side code can stop her typing it.
Second factors make a stolen password insufficient on its own (Module 14).
Passkeys cannot be phished at all: the browser only uses them on the real site.
Never redirect to untrusted addresses: an app that forwards people anywhere after sign-in becomes a tool for phishing. Better Auth only redirects to origins you trust.
Tell people about changes: an email on every password or email change warns the real owner.
QuizInteractive · disabled in the PDF
Which defence still works if Ana types her password into a perfect fake page?
Which defence still works if Ana types her password into a perfect fake page?
Better Auth37 / 170
Module 5 · How accounts get attacked
Credential stuffing and brute force: guessing at scale
Brute force
Many passwords against one account
Stopped by a limit per account
Long passwords make it hopeless
Credential stuffing
One leaked password against each of many accounts
Stopped by limits per address, bot checks
Refusing breached passwords and MFA defeat it
QuizInteractive · disabled in the PDF
The notes app allows 5 wrong passwords per account per 15 minutes. An attacker has 100,000 leaked email and password pairs and tries each once. Does the per-account limit slow them down?
The notes app allows 5 wrong passwords per account per 15 minutes. An attacker has 100,000 leaked email and password pairs and tries each once. Does the per-account limit slow them down?
Better Auth38 / 170
Module 5 · How accounts get attacked
Enumeration: do not say who has an account
Leaks
"No account with that email."
"That email is already registered."
Reset requests that answer faster for unknown emails
Says nothing
"That email and password don't match."
"Check your inbox to continue."
The same answer in the same time, either way
A list of which emails have accounts helps phishing ("we noticed a problem with your notes account") and focuses guessing. On some apps, simply having an account is private. Module 9 shows the Better Auth settings that let sign-up give the same answer either way.
Better Auth39 / 170
Module 5 · How accounts get attacked
CSRF: another site sends your request
Cross-site request forgery uses the fact that browsers attach cookies on their own. If Ana, signed in, visits a hostile page, that page can try to make her browser send a request to the notes app. If the cookie goes along, the server thinks Ana asked.
SameSite=Lax stops the browser attaching the cookie to cross-site posts.
The server checks the request's Origin header against origins it trusts; Better Auth does this on its own endpoints.
Requests that change things are never GETs, which links and images can trigger.
Better Auth40 / 170
Module 5 · How accounts get attacked
XSS: the attacker's script on your page
Cross-site scripting happens when text from a user is put into a page as HTML. If a note containing <script>…</script> is shown as HTML, the script runs for everyone who opens it, with all the power of the real page.
Find the bugInteractive · disabled in the PDF
This shows a note on the page. Which line lets a note run code?
JavaScript
Note. An HttpOnly cookie limits the damage: the script cannot read the cookie and send it away. It can still act as the user from inside the page, so preventing XSS matters even then.
Better Auth41 / 170
Module 5 · How accounts get attacked
Session theft: copying the key
A session cookie can be stolen by malware on the computer, read from a careless log file, or left behind on a shared library computer. The thief does not need the password: the cookie is enough.
QuizInteractive · disabled in the PDF
Which of these limit what a stolen session can do?
Which of these limit what a stolen session can do?
Better Auth42 / 170
Module 5 · How accounts get attacked
Where each defence comes in this course
Attack
Main defences
Module
Phishing
Passkeys, second factors, trusted redirects
14, 18
Credential stuffing
Rate limits, bot checks, breached-password check
16
Brute force
Per-account limits, slow hashing
4, 16
Enumeration
Same answer, same timing
9, 10
CSRF
SameSite, origin checks, trusted origins
7, 13
XSS
Escaping, HttpOnly, security headers
18
Session theft
Expiry, revoking, re-checking the password
11
Better Auth43 / 170
Module 5 · How accounts get attacked
Recap: attacks
Phishing steals passwords on fake pages: passkeys and second factors blunt it.
Brute force hits one account; stuffing hits many once each: limit both ways.
Never reveal which emails have accounts, in words or in timing.
CSRF rides on cookies the browser sends by itself: SameSite and origin checks stop it.
XSS runs a script on your page: show user text as text.
A stolen session is as good as a password: expire, revoke, re-check.
FlashcardsInteractive · disabled in the PDF
Credential stuffing
Trying pairs leaked from other sites, one try per account.
Brute force
Trying many passwords against one account.
Account enumeration
Learning which emails have accounts from answers or timing.
CSRF
Another site making your browser send a request with your cookies.
XSS
An attacker's script running inside your page.
Why are passkeys phishing-proof?
The browser only uses them on the real site.
Module 6: Installing Better Auth
Better Auth44 / 170
Module 6 · Installing Better Auth
What you need before the first line
Terminal
A window where you type commands instead of clicking.
Node.js
A program that runs JavaScript and TypeScript outside the browser: your server.
npm
The tool that downloads packages (code other people published) into your project.
Package
A library you install by name, such as better-auth or hono.
Terminal
1mkdirnotes-app&&cdnotes-app# a folder for the project2npminit-y# a package.json describing it3npminstallbetter-authbetter-sqlite34npminstall-Dtypescripttsx@types/better-sqlite3
better-sqlite3 is a small file-based database, ideal for learning. -D marks tools only needed while developing.
Tip. Check you have Node.js with node --version in a terminal. Any recent long-term-support version is fine.
Better Auth45 / 170
Module 6 · Installing Better Auth
A secret and a base URL, from the environment
Better Auth needs two settings. BETTER_AUTH_SECRET is a long random value it uses to sign cookies and links (Module 3). BETTER_AUTH_URL is the address your server is reached at. Both are environment variables: settings given to a program from outside its code, so secrets never sit in the code itself.
.env
1# make a secret with: openssl rand -base64 322BETTER_AUTH_SECRET=replace-with-your-own-random-value3BETTER_AUTH_URL=http://localhost:3000
Add .env to .gitignore, so it is never committed. Each environment (your laptop, production) has its own.
QuizInteractive · disabled in the PDF
In production, the secret is replaced with a new one. What happens?
In production, the secret is replaced with a new one. What happens?
Lines 1:betterAuth builds a configured instance from your options.
Lines 2: The SQLite driver. Better Auth also works with Postgres, MySQL and others.
Lines 4: Export it so the server (Module 7) and your own routes can use it. By convention it lives in auth.ts.
Lines 5: Where users and sessions are kept: here, a file called notes.db.
Lines 6-8: Turn on sign-up and sign-in with an email and password. Everything else is off until you ask.
Note. The secret and URL are read from BETTER_AUTH_SECRET and BETTER_AUTH_URL automatically. You can also pass secret and baseURL as options. In production, Better Auth refuses to start without a secret.
Better Auth47 / 170
Module 6 · Installing Better Auth
Four tables: user, session, account, verification
Table
One row per
Key columns
user
Person
email, emailVerified, name, image
session
Signed-in device
token, userId, expiresAt, ipAddress
account
Way to sign in
providerId, accountId, password, provider tokens
verification
Pending link or code
identifier, value, expiresAt
QuizInteractive · disabled in the PDF
Where does Better Auth keep the hash of Ana's password?
Where does Better Auth keep the hash of Ana's password?
Better Auth48 / 170
Module 6 · Installing Better Auth
The command-line tool creates the tables
A migration is a change to a database's structure, such as adding a table. Better Auth's command-line tool reads your auth.ts (plugins included) and either creates the tables directly or writes a schema file for your own migration tool.
Terminal
1npxauthmigrate# create the tables in notes.db2npxauthgenerate# or: write a schema file to review
Older guides call the tool @better-auth/cli (npx @better-auth/cli migrate). Check the docs for the version you run.
notes-app/
.env# secret and URL, never committed
.gitignore
notes.db# made by the migration
package.json
src/
auth.ts# the Better Auth instance
Tip. Run the tool again whenever you add a plugin or a field: many plugins add tables or columns.
Better Auth49 / 170
Module 6 · Installing Better Auth
With an ORM, the tables are part of your schema
An ORM is a library that lets code talk to the database in TypeScript instead of raw SQL. If your app uses Drizzle (or Prisma), give Better Auth an adapter for it. Its four tables then live in your own schema file, next to your notes table, and your usual migration tool creates them.
Fill the blanksInteractive · disabled in the PDF
Point Better Auth at a Drizzle database on SQLite.
TypeScript
1import{ betterAuth }from'better-auth'2import{}from'better-auth/adapters/drizzle'3import{ db }from'./db'4import*as schema from'./db/schema'56exportconst auth =betterAuth({7database:drizzleAdapter(db,{8provider:'',9 schema10}),11emailAndPassword:{enabled:true}12})
Better Auth50 / 170
Module 6 · Installing Better Auth
Two ways in: the handler and auth.api
`auth.handler(request)`
Answers HTTP requests to /api/auth/...
For browsers and the Better Auth client
Checks origins on the way, and rate-limits in production
`auth.api.*`
The same endpoints as functions: auth.api.getSession(...)
For your own server code
Trusted calls: no origin check, no rate limit
QuizInteractive · disabled in the PDF
Your own GET /notes route needs to know who is asking. Which do you use?
Your own GET /notes route needs to know who is asking. Which do you use?
Better Auth51 / 170
Module 6 · Installing Better Auth
Recap: installing
npm install better-auth plus a database driver or adapter.
BETTER_AUTH_SECRET and BETTER_AUTH_URL come from the environment; never commit them.
betterAuth({ database, emailAndPassword }) in auth.ts is the whole configuration to start.
Four tables: user, session, account, verification. Passwords live on the credential account.
The command-line tool creates tables or writes a schema; rerun it after adding plugins.
auth.handler serves HTTP; auth.api is for your own server code.
Try itInteractive · disabled in the PDF
FlashcardsInteractive · disabled in the PDF
Environment variable
A setting given to a program from outside its code.
What does BETTER_AUTH_SECRET sign?
Session cookies and verification links, among others.
The four core tables
user, session, account, verification.
The credential account
Email and password sign-in; it holds the password hash.
Migration
A change to the database's structure, such as adding a table.
auth.handler vs auth.api
HTTP endpoints vs the same endpoints as server functions.
Module 7: Serving it from a Hono server
Better Auth52 / 170
Module 7 · Serving it from a Hono server
Hono is a small web server framework
A server framework turns incoming requests into calls to your functions. Hono is a small, fast one that runs on Node.js and on edge platforms alike. You say "for GET /hello, run this", and it handles the rest. Each function gets c, the context: the request, and helpers to answer it.
src/server.ts
1import{ Hono }from'hono'2import{ serve }from'@hono/node-server'34const app =newHono()56app.get('/hello',(c)=> c.text('Hello from the notes app'))78serve({fetch: app.fetch,port:3000})
Install with npm install hono @hono/node-server, run with npx tsx src/server.ts, open http://localhost:3000/hello.
Better Auth53 / 170
Module 7 · Serving it from a Hono server
One route hands everything under /api/auth to Better Auth
Lines 7:app.on matches several methods at once. /api/auth/* matches every path under it: /api/auth/sign-in/email, /api/auth/get-session…
Lines 8:c.req.raw is the standard web Request. auth.handler takes one and returns a Response, which Hono sends back as it is.
Note./api/auth is Better Auth's default basePath. If you change basePath, change the route to match.
Better Auth54 / 170
Module 7 · Serving it from a Hono server
The endpoints it serves
Method and path
Does
POST /api/auth/sign-up/email
Make a user with an email and password
POST /api/auth/sign-in/email
Sign in; sets the session cookie
POST /api/auth/sign-out
End this session; clears the cookie
GET /api/auth/get-session
Who is signed in, or null
POST /api/auth/sign-in/social
Start GitHub or Google sign-in (Module 12)
GET /api/auth/callback/:provider
Where GitHub or Google send people back
POST /api/auth/request-password-reset
Email a reset link (Module 10)
Plugins add more under the same prefix. You rarely call these by hand: the client (Module 8) does it for you. But knowing they are plain HTTP makes them easy to test and debug.
Better Auth55 / 170
Module 7 · Serving it from a Hono server
Try it with curl
Terminal
1curl-ihttp://localhost:3000/api/auth/sign-up/email\2-H'Content-Type: application/json'\3-H'Origin: http://localhost:3000'\4-d'{"name":"Ana","email":"ana@example.com",5 "password":"a long passphrase here"}'
-d sends a JSON body, which makes it a POST. A \ at the end of a line continues the command on the next.
QuizInteractive · disabled in the PDF
With only emailAndPassword: { enabled: true }, what comes back from that sign-up?
With only emailAndPassword: { enabled: true }, what comes back from that sign-up?
Better Auth56 / 170
Module 7 · Serving it from a Hono server
Your routes ask auth.api who is signed in
Fill the blanksInteractive · disabled in the PDF
Answer GET /notes only for a signed-in person, with their own notes.
Tip. Module 13 turns this check into a middleware, so every protected route gets the user without repeating it.
Better Auth57 / 170
Module 7 · Serving it from a Hono server
Better Auth checks where requests come from
Browsers add an Origin header to requests made by pages. Better Auth compares it with its trusted origins: its own baseURL plus any you list. Requests from other origins that carry cookies, and sign-ins or sign-ups from them, are refused, which stops CSRF (Module 5). The same list decides where it may redirect people after sign-in.
List exact origins: scheme, host and port. Never a whole domain you do not control.
Better Auth58 / 170
Module 7 · Serving it from a Hono server
Pages on another origin need CORS
If the pages live at https://notes.example.com and the server at https://api.notes.example.com, browsers block the pages from reading the server's answers unless the server allows it. That permission is CORS (cross-origin resource sharing), set with response headers.
TypeScript
1import{ cors }from'hono/cors'23app.use('/api/auth/*',cors({4origin:'https://notes.example.com',5allowMethods:['GET','POST','OPTIONS'],6allowHeaders:['Content-Type'],7credentials:true8}))
Register CORS before the auth route, so it runs first.
QuizInteractive · disabled in the PDF
For signed-in requests from https://notes.example.com to work, which are needed?
For signed-in requests from https://notes.example.com to work, which are needed?
Better Auth59 / 170
Module 7 · Serving it from a Hono server
When nothing answers, check the path
Find the bugInteractive · disabled in the PDF
Every request to sign in answers 404. Which line is wrong?
TypeScript
Tip.GET /api/auth/ok answers { "ok": true }: a quick way to check the handler is mounted.
Better Auth60 / 170
Module 7 · Serving it from a Hono server
Recap: serving with Hono
Mount auth.handler(c.req.raw) on GET and POST at /api/auth/*, matching basePath.
Its endpoints are plain HTTP: sign-up, sign-in, sign-out, get-session and more.
In your own routes, auth.api.getSession({ headers }) returns the user or null.
Better Auth refuses requests from origins not in trustedOrigins.
Pages on another origin need CORS with an exact origin and credentials: true.
{ session, user }, or null when nobody is signed in.
trustedOrigins
Origins whose requests and redirect targets Better Auth accepts.
CORS
Headers that let pages on another origin read your responses.
Default session cookie name
better-auth.session_token.
Module 8: The client in the browser
Better Auth61 / 170
Module 8 · The client in the browser
The client is a typed wrapper around the endpoints
Your pages could call fetch('/api/auth/sign-in/email', …) by hand. The client does it for you: a small object with a method per endpoint, typed, that handles cookies and errors the same way every time.
src/lib/auth-client.ts
1import{ createAuthClient }from'better-auth/client'23exportconst authClient =createAuthClient({4// only needed if the server is on another origin5baseURL:'https://api.notes.example.com'6})
For React, import from better-auth/react instead: same methods, plus hooks. Vue, Svelte and Solid have their own entries.
Client call
Endpoint
authClient.signUp.email({ … })
POST /sign-up/email
authClient.signIn.email({ … })
POST /sign-in/email
authClient.signOut()
POST /sign-out
authClient.getSession()
GET /get-session
Better Auth62 / 170
Module 8 · The client in the browser
Every call answers with data or an error
Client calls do not throw when Better Auth refuses. They resolve to an object with two fields: data holds the answer when it worked, error describes the refusal when it did not, and the other one is null.
TypeScript
1const{ data, error }=await authClient.signUp.email({2name:'Ana',3email:'ana@example.com',4password:'a long passphrase here'5})67if(error){8showMessage(error.message ??'Something went wrong')9}else{10showMessage(`Welcome, ${data.user.name}`)11}
Predict the outputInteractive · disabled in the PDF
A sign-in with a wrong password runs this. What happens?
TypeScript
1try{2await authClient.signIn.email({ email, password })3 location.href ='/notes'4}catch{5showMessage('Wrong email or password')6}
A sign-in with a wrong password runs this. What happens?
Lines 3-4: Find the form and a place for messages on the page.
Lines 6-7: When the form is submitted, stop the browser from sending it the old way and reloading.
Lines 8:FormData reads the fields by their name attributes.
Lines 9-12: Ask Better Auth to sign in. On success, the server has set the session cookie; the page did not have to touch it.
Lines 13-14: Show the error as text (never as HTML), or go to the notes.
Better Auth64 / 170
Module 8 · The client in the browser
Who is signed in, and signing out
Fill the blanksInteractive · disabled in the PDF
Show who is signed in, and sign out when the button is clicked.
TypeScript
1const{ data: session }=await authClient.()2greeting.textContent = session3?`Signed in as ${session..email}`4:'Not signed in'56signOutButton.onclick=async()=>{7await authClient.()8 location.href ='/'9}
The notes app: longer passwords, and nobody signed in until their email is confirmed (Module 10).
Option
Default
What it does
minPasswordLength
8
Shorter passwords are refused
maxPasswordLength
128
Longer ones too: hashing huge input costs time
autoSignIn
true
Sign people in right after sign-up
requireEmailVerification
false
Refuse sign-in until the email is confirmed
disableSignUp
false
Sign-in only; accounts are made some other way
password.hash / verify
scrypt
Your own hashing functions
Better Auth70 / 170
Module 9 · Email and password, properly
What sign-up does inside
Note. Better Auth lowercases emails before saving and looking them up, so Ana@Example.com and ana@example.com are the same account.
Better Auth71 / 170
Module 9 · Email and password, properly
Sign-up that doesn't say who has an account
With autoSignIn: false or requireEmailVerification: true, signing up with an email that already has an account does not fail. Better Auth hashes the password anyway (so it takes as long), answers as if it worked, and calls onExistingUserSignUp if you set it, so you can email the real owner.
QuizInteractive · disabled in the PDF
With the defaults (autoSignIn: true, no verification required), what does signing up with an email that already has an account do?
With the defaults (autoSignIn: true, no verification required), what does signing up with an email that already has an account do?
TypeScript
1emailAndPassword:{2 enabled:true,3 requireEmailVerification:true,4 onExistingUserSignUp:async({ user })=>{5voidsendEmail({6to: user.email,7subject:'You already have a notes account',8text:'Someone tried to sign up with this email. '9+'If it was you, sign in or reset your password.'10})11}12}
sendEmail stands for whatever email service you use. void sends it without making the reply wait, so the answer takes as long either way (Module 10 explains why that matters).
Better Auth72 / 170
Module 9 · Email and password, properly
Sign-in, and what comes back
Finds the user by email and the credential account.
Verifies the password against the stored hash; for unknown emails it still hashes, so timing says nothing.
With requireEmailVerification, refuses an unconfirmed email with 403 EMAIL_NOT_VERIFIED.
Creates a session row, recording the IP address and browser.
Sets the signed session cookie and returns the user.
Remember me.signIn.email takes rememberMe (default true). With false, the cookie has no Max-Age, so the browser forgets it when it closes. Handy for a "This is a shared computer" box.
Better Auth73 / 170
Module 9 · Email and password, properly
Calling it from the server, and catching refusals
On the server, auth.api calls throw an APIError (from better-auth/api) when Better Auth refuses, unless you pass asResponse: true, which hands the refusal back as a Response with its status instead. error.status is the reason as text ("UNAUTHORIZED"), error.statusCode the number, and error.body.code a stable code. Anything else thrown is a real failure, such as the database being down.
Find the bugInteractive · disabled in the PDF
When the database is briefly unreachable, people are told their password is wrong. Which line causes it?
TypeScript
Better Auth74 / 170
Module 9 · Email and password, properly
Bringing your own password hash
password.hash(password) returns the string to store; password.verify({ hash, password }) says whether they match. Both are async. Better Auth uses them for sign-up, sign-in, changes and resets. A common reason: moving users from an older system whose hashes you must still accept.
Tune lengths, autoSignIn and requireEmailVerification under emailAndPassword.
Sign-up makes a user and a credential account; emails are lowercased.
With autoSignIn off or verification required, existing emails get the same answer.
Server calls throw APIError on refusal: handle that, rethrow everything else.
Custom hash and verify let you use Argon2 or accept old hashes.
changePassword needs the current password; revokeOtherSessions signs others out.
FlashcardsInteractive · disabled in the PDF
Why turn autoSignIn off?
So sign-up can answer the same for existing emails.
EMAIL_NOT_VERIFIED
The 403 sign-in gives with requireEmailVerification and an unconfirmed email.
onExistingUserSignUp
Called when someone signs up with a registered email: tell the owner.
What does asResponse: true give?
A full Response from an auth.api call, cookies included.
rememberMe: false
A cookie the browser drops when it closes.
INVALID_PASSWORD
The code for a wrong current password, as in changePassword.
Module 10: Emailed links: verification, resets and magic links
Better Auth77 / 170
Module 10 · Emailed links: verification, resets and magic links
An emailed link proves you can read the inbox
Typing an email proves nothing: anyone can type anyone's address. Opening a link that was sent to that address proves you can read its inbox. Three journeys rely on this.
Journey
The link
Default life
Verify email
A signed token holding the email; nothing stored
1 hour
Reset password
A random token stored in verification; works once
1 hour
Magic link (plugin)
A random token stored in verification; signs you in
5 minutes
Verification token
The secret part of an emailed link that proves it came from you.
Magic link
A link that signs you in without a password.
Better Auth78 / 170
Module 10 · Emailed links: verification, resets and magic links
Sending the confirmation email
WalkthroughInteractive · disabled in the PDF
src/auth.ts
1exportconst auth =betterAuth({2// database, emailAndPassword…3emailVerification:{4sendOnSignUp:true,5autoSignInAfterVerification:true,6sendVerificationEmail:async({ user, url })=>{7voidsendEmail({8to: user.email,9subject:'Confirm your email for Notes',10text:`Open this link to confirm: ${url}`11})12}13}14})
Lines 4: Send the email as part of sign-up, without being asked.
Lines 5: Opening the link also signs that browser in.
Lines 6: Your function: Better Auth gives you the user, a ready url to its /verify-email endpoint, and the bare token if you want to build your own link.
Lines 7-11:void starts sending without waiting for it. Next slide: why waiting is a leak.
Better Auth79 / 170
Module 10 · Emailed links: verification, resets and magic links
Send without making the answer wait
QuizInteractive · disabled in the PDF
Why does it matter for security, not just speed, that the reply does not wait for the email to be sent?
Why does it matter for security, not just speed, that the reply does not wait for the email to be sent?
Watch out. On serverless platforms, work left running after the reply can be cut off. Use the platform's way to keep it alive (on Cloudflare Workers, waitUntil), or put the email on a queue. Better Auth can do the first for you: with advanced: { backgroundTasks: { handler: waitUntil } }, it hands its verification and reset emails (and most others) to the platform instead of waiting for them.
Better Auth80 / 170
Module 10 · Emailed links: verification, resets and magic links
Require it, or gate what it unlocks
With requireEmailVerification: true, sign-in refuses unconfirmed emails, and emailVerification.sendOnSignIn: true sends a fresh link when they try. The alternative is to let people in but keep what depends on the email locked until it is confirmed.
QuizInteractive · disabled in the PDF
Notes can be shared "with anyone at @company.example". Why must that rule look only at confirmed emails?
Notes can be shared "with anyone at @company.example". Why must that rule look only at confirmed emails?
Better Auth81 / 170
Module 10 · Emailed links: verification, resets and magic links
Choosing a new password, end to end
TypeScript
1// server: emailAndPassword options2sendResetPassword:async({ user, url })=>{3voidsendEmail({to: user.email,subject:'Reset your password',4text:`Choose a new password: ${url}`})5},6// page 1: "Forgot password?"7await authClient.requestPasswordReset({8 email,redirectTo:'/reset-password'9})10// page 2: /reset-password?token=...11const token =newURLSearchParams(location.search).get('token')12await authClient.resetPassword({ newPassword,token: token!})
Put in orderInteractive · disabled in the PDF
Put a password reset in order.
1.Better Auth stores a random token and calls sendResetPassword
2.Better Auth saves the new hash and deletes the token so it cannot be used again
3.She types a new password; the page calls resetPassword with it and the token
4.Ana opens the link; Better Auth checks the token and sends her on to /reset-password?token=…
5.Ana asks for a link; the page says "check your email" whether or not the email exists
Better Auth82 / 170
Module 10 · Emailed links: verification, resets and magic links
After a reset, assume the old password was known
TypeScript
1emailAndPassword:{2 enabled:true,3 revokeSessionsOnPasswordReset:true,4 onPasswordReset:async({ user })=>{5voidsendEmail({6to: user.email,7subject:'Your password was changed',8text:'If this was not you, reset it again now.'9})10}11}
QuizInteractive · disabled in the PDF
Without revokeSessionsOnPasswordReset: true, what happens to a thief's session after Ana resets her password?
Without revokeSessionsOnPasswordReset: true, what happens to a thief's session after Ana resets her password?
Better Auth83 / 170
Module 10 · Emailed links: verification, resets and magic links
Magic links: sign in by email alone
Fill the blanksInteractive · disabled in the PDF
Add magic-link sign-in on the server and the client.
TypeScript
1// server2import{}from'better-auth/plugins'3plugins:[4magicLink({5sendMagicLink:async({ email, url })=>{6voidsendEmail({to: email,subject:'Sign in to Notes',7text:`Sign in: ${url}`})8}9})10]11// client12import{ magicLinkClient }from'better-auth/client/plugins'13const authClient =createAuthClient({14plugins:[()]15})16await authClient.signIn.({17 email,callbackURL:'/notes'18})
Better Auth84 / 170
Module 10 · Emailed links: verification, resets and magic links
Treat a link's token like a password
verification: { storeIdentifier: 'hashed' } keeps reset tokens only as hashes, so a database copy cannot reset anyone's password. The magic link plugin has storeToken: 'hashed'.
Pages that receive tokens should send Referrer-Policy: no-referrer, so the token does not travel to other sites in the Referer header.
Remove the token from the address bar once read, with history.replaceState.
Keep lifetimes short, and send at most a few emails per address per hour.
Email scanners click links. Some company mail systems open every link in an email to check it for malware. A single-use link that is spent by being opened can be used up before the person clicks. If people report "expired" links within seconds, that is a likely cause; a page that asks them to press a button before the token is used avoids it.
Better Auth85 / 170
Module 10 · Emailed links: verification, resets and magic links
Recap: emailed links
Links prove inbox access: verification, reset and magic links.
emailVerification.sendVerificationEmail gets { user, url, token }; sendOnSignUp sends it at once.
Send without making the reply wait, or timing reveals who has an account.
Grant nothing because of an email until emailVerified is true.
requestPasswordReset then resetPassword; revoke sessions and tell the owner.
Magic links come from the magicLink plugin; store tokens hashed and keep them short-lived.
FlashcardsInteractive · disabled in the PDF
Default life of a verification link
1 hour (emailVerification.expiresIn).
Default life of a reset link
1 hour (resetPasswordTokenExpiresIn).
Default life of a magic link
5 minutes.
autoSignInAfterVerification
Confirming the email also signs that browser in.
revokeSessionsOnPasswordReset default
false: turn it on.
storeIdentifier: 'hashed'
Keeps verification tokens only as hashes in the database.
Module 11: Sessions in depth
Better Auth86 / 170
Module 11 · Sessions in depth
getSession checks the cookie, the row and the clock
No cookie, no session: the answer is null.
A cookie whose signature does not match is ignored: forged, or made with an old secret.
The token finds the session row and its user.
Past expiresAt, the session is over.
If it was last extended more than updateAge ago, expiresAt moves to now plus expiresIn, and a new cookie is set.
The caller gets the session and the user.
Better Auth87 / 170
Module 11 · Sessions in depth
expiresIn and updateAge make a sliding window
src/auth.ts
1session:{2 expiresIn:60*60*24*30,// 30 days, in seconds3 updateAge:60*60*24// extend at most once a day4}
Defaults: expiresIn 7 days, updateAge 1 day. Both are in seconds.
QuizInteractive · disabled in the PDF
With the defaults (7 days, 1 day), Ana signs in Monday at 09:00. She uses the app Monday at 15:00 and Tuesday at 10:00, then not again. When does her session expire?
With the defaults (7 days, 1 day), Ana signs in Monday at 09:00. She uses the app Monday at 15:00 and Tuesday at 10:00, then not again. When does her session expire?
Better Auth88 / 170
Module 11 · Sessions in depth
A refreshed session comes with a new cookie
When a session is extended, the cookie must be extended too, or the browser drops it at its old Max-Age. Through the handler this is automatic. When your own route calls auth.api, ask for the headers and copy the Set-Cookie onto your reply.
getSetCookie() keeps each cookie separate; append keeps any already on the reply.
QuizInteractive · disabled in the PDF
A middleware calls getSession without passing refreshed cookies on. Sessions last 7 days. What do people notice?
A middleware calls getSession without passing refreshed cookies on. Sessions last 7 days. What do people notice?
Better Auth89 / 170
Module 11 · Sessions in depth
A fresh session for dangerous actions
A session can be weeks old, and the person at the keyboard might not be its owner. session.freshAge (default 1 day) marks a session as fresh for that long after it was created. Some endpoints require a fresh session: listSessions and unlinkAccount answer 403 SESSION_NOT_FRESH otherwise, and deleting the account without a password answers SESSION_EXPIRED.
Fresh session
Signing in recently is the proof
Nothing extra to type
Fails when the session is old
Ask for the password
The password is the proof, however old the session
Expert note. Re-checking before changing the password or email, or deleting the account, is what stops a stolen session from becoming a stolen account.
Better Auth90 / 170
Module 11 · Sessions in depth
A "where you are signed in" page
Client call
Does
authClient.listSessions()
Lists the user's sessions, with IP and browser
authClient.revokeSession({ token })
Ends one of them
authClient.revokeOtherSessions()
Ends all but this one
authClient.revokeSessions()
Ends all of them, this one included
Fill the blanksInteractive · disabled in the PDF
List Ana's sessions and sign out every other device.
TypeScript
1const{ data: sessions }=await authClient.()2for(const s of sessions ??[])3addRow(s.userAgent ??'Unknown browser', s.)45signOutOthers.onclick=()=> authClient.()
On an HTTPS baseURL. On http://localhost there is no __Secure- prefix and no Secure.
HttpOnly, SameSite=Lax and Path=/ are always the defaults.
Secure and the __Secure- prefix are added when the base URL is HTTPS (or advanced.useSecureCookies says so). Browsers refuse a __Secure- cookie that isn't Secure.
advanced.cookiePrefix changes better-auth to your own name; advanced.defaultCookieAttributes changes attributes for every cookie.
QuizInteractive · disabled in the PDF
What does the __Secure- prefix on the cookie name add?
What does the __Secure- prefix on the cookie name add?
Better Auth92 / 170
Module 11 · Sessions in depth
The cookie cache trades revocation for speed
Every getSession reads the database. With session.cookieCache: { enabled: true, maxAge: 300 }, Better Auth also sets a signed session_data cookie holding the session itself, and trusts it for up to maxAge seconds without a lookup.
QuizInteractive · disabled in the PDF
With a 5-minute cookie cache, Ana signs out a stolen laptop from her phone. How long can the laptop keep working?
With a 5-minute cookie cache, Ana signs out a stolen laptop from her phone. How long can the laptop keep working?
Better Auth93 / 170
Module 11 · Sessions in depth
Recap: sessions
getSession checks the signature, the row and the expiry, and extends sessions older than updateAge.
expiresIn 7 days and updateAge 1 day by default, in seconds.
Pass refreshed Set-Cookie headers on when calling auth.api yourself.
Dangerous actions want a fresh session or the password.
listSessions, revokeSession, revokeOtherSessions, revokeSessions make a devices page.
Cookies are HttpOnly, SameSite=Lax, and Secure with __Secure- on HTTPS.
The cookie cache skips lookups but delays revocation by up to maxAge.
FlashcardsInteractive · disabled in the PDF
When does getSession extend a session?
When it was last extended more than updateAge ago.
Default expiresIn / updateAge / freshAge
7 days / 1 day / 1 day.
Why pass headers on after getSession?
A refreshed session comes with a new Set-Cookie.
SESSION_NOT_FRESH and SESSION_EXPIRED
What endpoints that need a fresh session answer when this one is older than freshAge.
__Secure- prefix
Browsers accept the cookie only with Secure, from HTTPS.
Cookie cache trade-off
Fewer database reads; revoked sessions last up to maxAge longer.
Module 12: Social sign-in with OAuth, and linking accounts
Better Auth94 / 170
Module 12 · Social sign-in with OAuth, and linking accounts
OAuth lets another service vouch for someone
With "Sign in with GitHub", Ana proves who she is to GitHub, and GitHub tells the notes app, through a short exchange the app can verify. The notes app never sees her GitHub password. OAuth 2.0 is the name of that exchange. OpenID Connect is a layer on top that adds a signed statement of who signed in, used by Google and others.
Think of a hotel guest letting a friend into their room: instead of handing over their own key, they ask the front desk to issue a limited key card. OAuth issues the app a limited permission, never the password itself.
Provider
The service that vouches: GitHub, Google, Apple…
Client id and client secret
Your app's identity at the provider. The id is public; the secret stays on your server.
Redirect URI
Where the provider sends the browser back. Registered in advance; must match exactly.
Scope
What you ask to see: user:email at GitHub, openid email profile at Google.
Better Auth95 / 170
Module 12 · Social sign-in with OAuth, and linking accounts
The authorization code flow, end to end
Better Auth runs everything the notes server does here. You register the app and configure the provider.
Authorization code
A one-time code in the redirect, worth nothing until your server exchanges it.
Access token
What the exchange returns: permission to call the provider's API for that user.
Better Auth96 / 170
Module 12 · Social sign-in with OAuth, and linking accounts
Register the app with the provider first
In GitHub's developer settings, create an OAuth app.
Set its callback URL to http://localhost:3000/api/auth/callback/github for development; production gets its own app or URL.
Copy the client id, and generate a client secret.
Do the same for Google in its cloud console, with /api/auth/callback/google.
Note. Per provider, scope asks for more, disableSignUp lets only existing users sign in with it, and mapProfileToUser copies fields from the profile onto your user.
Better Auth98 / 170
Module 12 · Social sign-in with OAuth, and linking accounts
State and PKCE close two different holes
State is a random value sent out and expected back. It stops login CSRF: an attacker sending your browser to the callback with their code, so you end up signed in as them and type your notes into their account.
PKCE (said "pixie") is a secret verifier: only its hash goes out at the start, the verifier itself only at the exchange. A stolen code is useless without it.
QuizInteractive · disabled in the PDF
A malicious browser extension reads the callback address, code included. With PKCE, what can it do with the code?
A malicious browser extension reads the callback address, code included. With PKCE, what can it do with the code?
Better Auth99 / 170
Module 12 · Social sign-in with OAuth, and linking accounts
One user, one account per way in
user: ana@example.com
account: credentialpassword hash
account: githubaccountId = GitHub's id for Ana
account: googleaccountId = Google's id for Ana
QuizInteractive · disabled in the PDF
Ana changes the email on her GitHub account. Next time she signs in with GitHub, how does Better Auth find her?
Ana changes the email on her GitHub account. Next time she signs in with GitHub, how does Better Auth find her?
Better Auth100 / 170
Module 12 · Social sign-in with OAuth, and linking accounts
Linking on first sign-in, only with verified emails
Ana signed up with a password. Months later she clicks "Sign in with Google" with the same email. Better Auth can link the Google account to her existing user instead of failing. It does so only when the provider says the email is verified (or is listed in accountLinking.trustedProviders) and, by default, when the local email is verified too.
QuizInteractive · disabled in the PDF
An attacker signs up with a password using Ana's email, never confirming it. Later Ana clicks "Sign in with Google". Why does Better Auth refuse to link by default?
An attacker signs up with a password using Ana's email, never confirming it. Later Ana clicks "Sign in with Google". Why does Better Auth refuse to link by default?
Better Auth101 / 170
Module 12 · Social sign-in with OAuth, and linking accounts
Linking on purpose, while signed in
TypeScript
1// on the settings page, signed in already2await authClient.linkSocial({3provider:'github',4callbackURL:'/settings'5})6const{ data: accounts }=await authClient.listAccounts()7const github = accounts?.find((a)=> a.providerId ==='github')8if(github)await authClient.unlinkAccount({accountId: github.id })
Despite its name, unlinkAccount's accountId is the account row's own id, from listAccounts: not the provider's name, and not the row's accountId column.
Linking runs the same OAuth flow, then attaches the account to the signed-in user.
By default the provider's email must match the user's; allowDifferentEmails changes that, at a risk.
Unlinking needs a fresh session (Module 11), and unlinking the last way in is refused unless allowUnlinkingAll is on, so nobody locks themselves out.
disableImplicitLinking turns off linking at sign-in, leaving only this explicit way.
Expert note. Provider access and refresh tokens are saved on the account, in plain text unless account.encryptOAuthTokens: true. If you never call the provider's API, you don't need them; if you do, encrypt them.
Better Auth102 / 170
Module 12 · Social sign-in with OAuth, and linking accounts
Recap: social sign-in
OAuth: redirect to the provider, code back, server-side exchange, profile, session.
Register the app; the redirect URI is /api/auth/callback/<provider>.
socialProviders with secrets from the environment; signIn.social starts it.
State ties the callback to the browser; PKCE makes a stolen code useless.
Accounts are found by provider and the provider's id, not by email.
Implicit linking needs a verified email on both sides; linkSocial links on purpose.
FlashcardsInteractive · disabled in the PDF
OAuth 2.0
A way for a provider to vouch for someone without sharing their password.
Authorization code
A one-time code your server exchanges for tokens.
What does state prevent?
Login CSRF: finishing someone else's sign-in in your browser.
What does PKCE prevent?
Exchanging an intercepted authorization code.
Default callback path for GitHub
/api/auth/callback/github.
requireLocalEmailVerified
Never link into a local account whose email was not confirmed. On by default.
Module 13: Protecting routes, and roles
Better Auth103 / 170
Module 13 · Protecting routes, and roles
A middleware puts the user on the context
A middleware is a function that runs before your route handlers. Instead of calling getSession in every route, one middleware does it and stores the result on c, Hono's context, where every handler can read it.
$Infer.Session is the exact type of a session with your options and plugins, so c.get('user') is fully typed.
Better Auth104 / 170
Module 13 · Protecting routes, and roles
requireUser: only signed-in people get through
WalkthroughInteractive · disabled in the PDF
src/server.ts
1import{ createMiddleware }from'hono/factory'23const requireUser =createMiddleware<AppEnv>(async(c, next)=>{4if(!c.get('user'))5return c.json({error:'Sign in first'},401)6awaitnext()7})89app.get('/notes', requireUser,async(c)=>{10const user = c.get('user')!11return c.json(awaitnotesOf(user.id))12})
Lines 3:createMiddleware<AppEnv> types c.get with the same variables as the app.
Lines 4-5: No user: answer 401 and never reach the handler.
Lines 6: Otherwise continue to the next middleware or the handler.
Lines 9-12: Routes list their guards before the handler. Inside, the user is certainly there.
Tip. Looking the session up on every request costs a database read even for public pages. Mount the lookup only on the paths that need it, such as /notes/* and /settings/*.
Better Auth105 / 170
Module 13 · Protecting routes, and roles
Authorization: check the row is theirs
A signed-in person can send any id in a URL. If the handler finds a note by id alone, anyone can read anyone's notes. This hole has a name: an insecure direct object reference (IDOR). Put the owner into the query itself.
Why answer 404, not 403, for a note that exists but belongs to someone else?
Why answer 404, not 403, for a note that exists but belongs to someone else?
Better Auth106 / 170
Module 13 · Protecting routes, and roles
Your own routes need their own CSRF check
Better Auth checks the origin of requests to its endpoints. Routes you write, like POST /notes or DELETE /notes/:id, are yours to guard. Hono's csrf middleware refuses the state-changing requests another site can make a browser send without asking first (forms, plain text, no body) when their Origin is not yours. JSON requests from another origin need CORS's permission anyway.
The session cookie is SameSite=Lax. Why add an origin check to your own routes as well?
The session cookie is SameSite=Lax. Why add an origin check to your own routes as well?
Better Auth107 / 170
Module 13 · Protecting routes, and roles
Roles name what groups of people may do
A role is a label on a user, like user or admin, with permissions attached. Checking roles instead of individual people is called role-based access control (RBAC). Better Auth's admin plugin adds a role column, the endpoints to manage users, and checks of its own.
Acting on a row by id without checking it belongs to the caller.
RBAC
Role-based access control: permissions attached to roles, roles to users.
Default roles in the admin plugin
user for everyone, admin to manage users.
impersonatedBy
Marks a session an admin started as another user.
401 vs 403 vs 404
Not signed in / not allowed / not yours, or not there.
Module 14: Plugins: two-step sign-in, passkeys and teams
Better Auth111 / 170
Module 14 · Plugins: two-step sign-in, passkeys and teams
A plugin adds endpoints, tables and behaviour
The core of Better Auth is small on purpose. Plugins go in plugins: [...] on the server, from better-auth/plugins. Most have a client half, from better-auth/client/plugins, that adds typed methods to authClient. A plugin can add endpoints, tables or columns, hooks and rate limits of its own.
Plugin
Adds
twoFactor
Codes from an authenticator app, backup codes
magicLink, emailOTP
Sign-in by emailed link or code
username
Sign-in with a username as well as an email
admin
Roles, bans, impersonation (Module 13)
organization
Teams, members, invitations, roles
haveIBeenPwned, captcha
Breached-password and bot checks (Module 16)
bearer, jwt
Sessions as bearer tokens; JWTs for other services
multiSession
Several accounts signed in on one browser
Better Auth112 / 170
Module 14 · Plugins: two-step sign-in, passkeys and teams
Adding a plugin is a small migration
Put in orderInteractive · disabled in the PDF
Add the twoFactor plugin to a running app. Put the steps in order.
1.Run the migration: the tool reads the plugin's schema and adds its table and column
2.Add twoFactor() to the server's plugins and twoFactorClient() to the client's
3.Migrate the production database
4.Deploy the new code
5.Build the pages and try them locally: turn it on, then sign in with a code
Better Auth113 / 170
Module 14 · Plugins: two-step sign-in, passkeys and teams
Two-factor sign-in with an authenticator app
An authenticator app (on a phone or in a password manager) shares a secret with the server once, by scanning a QR code. From then on, both compute the same 6-digit code from that secret and the current time, changing every 30 seconds. This is called TOTP, a time-based one-time password.
WalkthroughInteractive · disabled in the PDF
TypeScript
1// server2plugins:[twoFactor({issuer:'Notes'})]34// settings page: turning it on needs the password5const{ data }=await authClient.twoFactor.enable({6 password7})8if(data?.method ==='totp'){9showQrCode(data.totpURI)10showOnce(data.backupCodes)11}1213// confirm the app works before relying on it14await authClient.twoFactor.verifyTotp({ code })
Lines 2:issuer is the name shown in the authenticator app next to the code.
Lines 5-7: Asking for the password stops someone at an unlocked laptop from tying the account to their phone.
Lines 8-9:enable can also turn on codes sent by email or text message (method: 'otp'), so the answer says which it did. With the default, totp, it carries totpURI: an otpauth:// address holding the secret. Draw it as a QR code with any QR library.
Lines 10: Backup codes, for when the phone is lost. Shown once.
Lines 14: A first correct code proves the app was set up right.
Better Auth114 / 170
Module 14 · Plugins: two-step sign-in, passkeys and teams
Signing in with a second factor
TypeScript
1const authClient =createAuthClient({2plugins:[3twoFactorClient({4onTwoFactorRedirect(){5 location.href ='/two-factor'6}7})8]9})10// on /two-factor:11await authClient.twoFactor.verifyTotp({ code })
Once it is on, the right password alone does not sign anyone in. Sign-in answers that a second factor is needed, the client calls onTwoFactorRedirect, and only a correct code (or a backup code, through verifyBackupCode) creates the session.
QuizInteractive · disabled in the PDF
A 6-digit code has a million possible values. Why must code checks be rate-limited as hard as passwords?
A 6-digit code has a million possible values. Why must code checks be rate-limited as hard as passwords?
Better Auth115 / 170
Module 14 · Plugins: two-step sign-in, passkeys and teams
Backup codes are passwords too
Show them once, right after turning two-factor on, and ask people to save them somewhere safe.
Each works only once.
authClient.twoFactor.generateBackupCodes({ password }) replaces the whole set, for when they are used up or exposed.
Better Auth stores them encrypted with your secret, not in plain text.
QuizInteractive · disabled in the PDF
Ana loses her phone and her backup codes. What is the safest recovery an app can offer?
Ana loses her phone and her backup codes. What is the safest recovery an app can offer?
Better Auth116 / 170
Module 14 · Plugins: two-step sign-in, passkeys and teams
Passkeys: sign-in that cannot be phished
A passkey is a pair of keys made by your device. The private key never leaves it (often protected by a fingerprint or face check); the server stores only the public key. To sign in, the server sends a random challenge, the device signs it, and the server checks the signature with the public key.
Nothing secret is stored on the server, so a database leak reveals nothing usable.
The browser ties each passkey to the real site, so a fake page cannot use it.
There is nothing to type, guess or reuse.
In Better Auth 1.7. Passkeys come as a plugin in a separate package (@better-auth/passkey), with a server half and a client half like every other plugin. Check its documentation for your exact version before copying setup code.
Better Auth117 / 170
Module 14 · Plugins: two-step sign-in, passkeys and teams
Organizations: shared notebooks for teams
TypeScript
1// server2plugins:[3organization({4sendInvitationEmail:async({ email, id, inviter })=>{5voidsendEmail({to: email,6subject:`${inviter.user.name} invited you to Notes`,7text:`Join: https://notes.example.com/join/${id}`})8}9})10]11// client, with organizationClient()12await authClient.organization.create({name:'Book club',13slug:'book-club'})14await authClient.organization.inviteMember({15email:'ben@example.com',role:'member'})
An organization has members, each with a role: owner (the creator), admin or member. Invitations are emailed; acceptInvitation({ invitationId }) joins. The session remembers an active organization, set with setActive, so the app knows which team's notebooks to show.
QuizInteractive · disabled in the PDF
Ben's session has an active organization. A route lists that team's notebooks. What must it still check?
Ben's session has an active organization. A route lists that team's notebooks. What must it still check?
Better Auth118 / 170
Module 14 · Plugins: two-step sign-in, passkeys and teams
Recap: plugins
Plugins add endpoints, tables, hooks and limits; most pair a server and a client half.
A plugin that adds data needs a migration before its code goes live.
twoFactor: enable with the password, scan totpURI, then sign-in asks for a code.
Backup codes are single-use passwords: show once, regenerate when needed.
Passkeys store only public keys and cannot be phished.
Organizations add members, roles, invitations and an active organization on the session.
FlashcardsInteractive · disabled in the PDF
Where do client plugins come from?
better-auth/client/plugins.
TOTP
A 6-digit code computed from a shared secret and the time.
What does twoFactor.enable return?
A totpURI for the authenticator app, and backup codes.
onTwoFactorRedirect
Client callback when sign-in needs a second factor.
Passkey
A key pair on your device; the server keeps only the public key.
Default organization roles
owner, admin, member.
Module 15: Hooks and fields of your own
Better Auth119 / 170
Module 15 · Hooks and fields of your own
Three ways to add your own behaviour
Option
Runs
For
hooks.before / hooks.after
Around an endpoint call
Refusing requests, reacting to results
databaseHooks
Around a write to a table
Changing rows, audit entries, side effects
user.additionalFields
Always: more columns
Data of your own on users or sessions
Hook
A function a library calls at a set moment, so your code can take part.
Endpoint
One address the server answers, like /sign-up/email.
Better Auth120 / 170
Module 15 · Hooks and fields of your own
A before hook can refuse a request
WalkthroughInteractive · disabled in the PDF
src/auth.ts
1import{ APIError, createAuthMiddleware }from'better-auth/api'23const blocked =newSet(['throwaway.example','spam.example'])45exportconst auth =betterAuth({6hooks:{7before:createAuthMiddleware(async(ctx)=>{8if(ctx.path !=='/sign-up/email')return9const domain =String(ctx.body?.email).split('@')[1]10if(blocked.has(domain?.toLowerCase()))11thrownewAPIError('BAD_REQUEST',{12message:'Please use a permanent email address.'13})14})15}16})
Lines 1: Both come from better-auth/api.
Lines 3: Email domains the notes app refuses (made-up examples).
Lines 7:createAuthMiddleware gives ctx: the path without the /api/auth prefix, the body, the headers.
Lines 8: One hook sees every endpoint, so check the path first and leave the rest alone.
Lines 11-13: Throwing an APIError becomes a proper error response for the client, and a thrown error for auth.api callers.
Better Auth121 / 170
Module 15 · Hooks and fields of your own
Practice: close sign-in for suspended emails
Fill the blanksInteractive · disabled in the PDF
Refuse email sign-in for addresses on a suspended list, before Better Auth checks the password.
TypeScript
1hooks:{2:createAuthMiddleware(async(ctx)=>{3if(ctx.!=='/sign-in/email')return4const email =String(ctx.body?.email).toLowerCase()5if(suspended.has(email))6thrownew('FORBIDDEN',{7message:'This account is suspended.'8})9})10}
Better Auth122 / 170
Module 15 · Hooks and fields of your own
An after hook reacts to what happened
TypeScript
1hooks:{2 after:createAuthMiddleware(async(ctx)=>{3if(!ctx.path.startsWith('/sign-in'))return4const created = ctx.context.newSession5if(!created)return6voidsendEmail({7to: created.user.email,8subject:'New sign-in to Notes',9text:`A new sign-in from ${created.session.userAgent}`10})11})12}
ctx.context.newSession is set when the endpoint just created a session.
QuizInteractive · disabled in the PDF
This after hook checks ctx.path.startsWith('/sign-in'). Does it email Ana when she signs in with GitHub?
This after hook checks ctx.path.startsWith('/sign-in'). Does it email Ana when she signs in with GitHub?
Rerun the migration to add the column. On the client, inferAdditionalFields<typeof auth>() types it.
input: true means callers may set the field, for example in the signUp.email body or with updateUser. That is right for a time zone. It is a hole for anything that grants power.
QuizInteractive · disabled in the PDF
A teammate adds plan: { type: 'string', defaultValue: 'free', input: true } and paid features check user.plan === 'pro'. What can anyone do?
A teammate adds plan: { type: 'string', defaultValue: 'free', input: true } and paid features check user.plan === 'pro'. What can anyone do?
An invite-only notes app: people are added by an admin, and cannot edit their own profile through Better Auth.
Tip. Every endpoint you serve is something to secure. If the app does not use one, turn it off.
Better Auth126 / 170
Module 15 · Hooks and fields of your own
Recap: hooks and fields
hooks.before and hooks.after, made with createAuthMiddleware, wrap endpoint calls.
Check ctx.path first; throw APIError to refuse.
ctx.context.newSession in an after hook catches every way of signing in.
databaseHooks wrap Better Auth's own writes, whatever endpoint caused them.
additionalFields add columns; input: true fields are untrusted input.
disabledPaths and disableSignUp remove what you do not use.
FlashcardsInteractive · disabled in the PDF
hooks vs databaseHooks
Around endpoint calls vs around Better Auth's writes.
How does a hook refuse a request?
Throw an APIError, such as new APIError('FORBIDDEN', { message }).
What can a databaseHooksbefore return?
{ data } to change the row, or false to stop the write.
input: true on a field
Callers may set it in requests.
Do database hooks see your own direct writes?
No, only writes Better Auth makes.
disabledPaths
Endpoints Better Auth will not serve.
Module 16: Rate limits, bots and breached passwords
Better Auth127 / 170
Module 16 · Rate limits, bots and breached passwords
Defence in layers, cheapest first
Limits per IP addressfloods and scripts from one place
Limits per accountmany guesses at one email
A human checka captcha on sign-up, sign-in and resets
The slow password hashsame answer either way
Password ruleslong, not breached: guesses rarely land
Each layer stops what got past the one before, as cheaply as it can.
No single limit stops everything: per-IP limits miss attacks spread over thousands of addresses, per-account limits miss one password tried once against each of many accounts, and captchas can be paid around. Together, each attack meets a layer it can't cheaply beat.
Better Auth128 / 170
Module 16 · Rate limits, bots and breached passwords
Better Auth's rate limiter
src/auth.ts
1rateLimit:{2 enabled:true,// default: only in production3 window:60,// seconds (default 10)4 max:100,// requests per window (default 100)5 storage:'database',// default 'memory'6 customRules:{7'/sign-in/email': {window:60,max:5},8'/get-session': false9}10}
Watch out. The limiter counts per client IP and path, and only for requests through the handler. auth.api calls from your own routes are trusted and never limited: limit those routes yourself.
Better Auth129 / 170
Module 16 · Rate limits, bots and breached passwords
Counts must be shared by every server
QuizInteractive · disabled in the PDF
The notes app runs on four servers behind a load balancer. Why is the default storage: 'memory' a weak limit?
The notes app runs on four servers behind a load balancer. Why is the default storage: 'memory' a weak limit?
Better Auth130 / 170
Module 16 · Rate limits, bots and breached passwords
Know which IP you are limiting
Per-IP limits are only as good as the IP. Behind a proxy or CDN, the server sees the proxy's address, and the client's comes in a header. But X-Forwarded-For is a header anyone can send. Tell Better Auth which header your platform sets and the client cannot.
TypeScript
1advanced:{2 ipAddress:{3// Cloudflare sets this and overwrites any client value4 ipAddressHeaders:['cf-connecting-ip']5}6}
Default: x-forwarded-for, and only when it holds a single address, unless trustedProxies lists your proxies.
Find the bugInteractive · disabled in the PDF
This per-IP limit on a custom route runs behind Cloudflare. Which line lets one attacker look like thousands of addresses?
TypeScript
Better Auth131 / 170
Module 16 · Rate limits, bots and breached passwords
Lockouts can be turned against the account
Counting wrong passwords per email stops brute force on one account. Better Auth's limiter does not do this (it counts per IP and path), so this layer is yours to add, for example in a before hook. But a hard lock is also a weapon: anyone who knows Ana's email could keep her locked out forever.
QuizInteractive · disabled in the PDF
An attacker sends 10 wrong passwords for Ana's email every 15 minutes. What keeps Ana able to get in?
An attacker sends 10 wrong passwords for Ana's email every 15 minutes. What keeps Ana able to get in?
Tip. Count attempts for any email typed, existing or not. Otherwise "too many attempts" appears only for real accounts and reveals them.
Better Auth132 / 170
Module 16 · Rate limits, bots and breached passwords
A captcha checks a person is there
A captcha is a check that a human, not a script, filled in the form. Modern ones, like Cloudflare Turnstile, usually run invisibly in the page and hand it a short-lived token. The server sends the token to the provider to confirm it.
Fill the blanksInteractive · disabled in the PDF
Require Turnstile on sign-up, sign-in and reset requests.
TypeScript
1import{}from'better-auth/plugins'23plugins:[4captcha({5provider:'',6secretKey: process.env.TURNSTILE_SECRET_KEY!7})8]9// the page sends the widget's token in a header:10await authClient.signIn.email({ email, password },{11headers:{'': token }12})
Better Auth133 / 170
Module 16 · Rate limits, bots and breached passwords
Refuse passwords that are already leaked
TypeScript
1import{ haveIBeenPwned }from'better-auth/plugins'23plugins:[4haveIBeenPwned({5customPasswordCompromisedMessage:6'This password appears in a known data breach. '7+'Please choose another.'8})9]
Checked at sign-up, password changes and resets by default (paths changes that).
QuizInteractive · disabled in the PDF
The plugin asks the Have I Been Pwned service about each new password. What does it actually send?
The plugin asks the Have I Been Pwned service about each new password. What does it actually send?
Better Auth134 / 170
Module 16 · Rate limits, bots and breached passwords
Recap: rate limits and bots
Layer defences: per IP, per account, a human check, the slow hash, password rules.
Better Auth's limiter: on in production, 3 per 10 seconds for sign-in by default, handler requests only.
Use shared storage (database or secondary-storage) when there is more than one server.
Read the client IP from a header your platform sets: ipAddressHeaders.
Keep account locks short and recoverable, and count unknown emails too.
The captcha and haveIBeenPwned plugins check people and passwords; both fail closed.
FlashcardsInteractive · disabled in the PDF
Better Auth's default sign-in limit
3 requests per 10 seconds, per IP.
Is the limiter on in development?
No: by default only in production.
Are auth.api calls rate-limited?
No: limit your own routes yourself.
Why not memory storage with several servers?
Each server counts on its own.
Captcha token header
x-captcha-response.
k-anonymity in breach checks
Send only a hash prefix; compare the rest locally.
Module 17: Testing auth flows
Better Auth135 / 170
Module 17 · Testing auth flows
Test the flows, not the library
Better Auth has its own tests. Yours check your configuration and your routes: that sign-up needs a confirmed email, that a reset link works once, that Ben cannot read Ana's notes. A wrong option or a missing check is exactly what these tests catch.
Kind
Checks
Speed
Integration test
Real requests to the auth handler and your routes, database in memory
Fast: milliseconds
End-to-end test
A real browser clicking through pages (for example with Playwright)
Slower: seconds
Test runner
A tool that finds and runs test files and reports failures: Vitest here.
Assertion
A line that fails the test if something is not as expected: expect(x).toBe(1).
Lines 2: The memory adapter keeps tables in plain arrays: no database to set up, fresh every run.
Lines 4: Emails go into an array instead of out, so tests can read the links.
Lines 7-8: A fixed base URL and a throwaway secret, only ever used in tests.
Lines 9: When NODE_ENV is test, as under Vitest, Better Auth skips its origin and CSRF checks unless this says false. Turn them back on, so tests can prove foreign origins are refused.
Lines 13-19: The same options as production. Better: build both from one shared function, so tests check the real configuration.
A __Host- cookie name is the strictest prefix browsers enforce: the cookie must be Secure, have Path=/, and have noDomain, so no subdomain can read, set or shadow it. Better Auth uses __Secure- on HTTPS; you can choose __Host- yourself.
WalkthroughInteractive · disabled in the PDF
src/auth.ts
1advanced:{2// otherwise Better Auth adds __Secure- in front of the name3 useSecureCookies:false,4 defaultCookieAttributes:{5 secure:true, httpOnly:true, sameSite:'lax', path:'/'6},7 cookies:{8 session_token:{ name:'__Host-notes_session'}9}10}
Lines 2-3: Secure-cookie mode prefixes __Secure- to every name. Turned off, the name stays exactly as given…
Lines 4-6: …so the attributes are set by hand instead: Secure, HttpOnly, SameSite=Lax, the whole site.
Lines 7-9: Rename only the session token cookie; the others keep their defaults.
QuizInteractive · disabled in the PDF
If useSecureCookies were left on with that name, what would the browser receive?
If useSecureCookies were left on with that name, what would the browser receive?
Better Auth144 / 170
Module 18 · Security hardening
Trusted origins guard redirects and requests
trustedOrigins, plus the base URL's own origin, is the list Better Auth checks callbackURL, redirectTo and similar parameters against, and the Origin of incoming requests. Keep it to exact origins you control. It also accepts a function of the request, for setups like preview deployments.
QuizInteractive · disabled in the PDF
A team sets advanced.disableOriginCheck: true because a preview deployment's sign-in failed. What did they open?
A team sets advanced.disableOriginCheck: true because a preview deployment's sign-in failed. What did they open?
Better Auth145 / 170
Module 18 · Security hardening
Secrets: strong, separate, rotatable
At least 32 random bytes (openssl rand -base64 32), different in every environment.
Kept in the platform's secret store, never in code, logs or error messages.
Separate from any other key, such as a password pepper or a payment webhook secret, so one leak or change does not drag the others along.
Replacing secret signs everyone out, breaks email-verification links already sent, and leaves what was encrypted with it (two-factor secrets, encrypted provider tokens) unreadable: plan it, and rotate with secrets below.
Versioned secrets for what Better Auth encrypts: the first encrypts new data, the rest only decrypt, so stored encrypted values survive a rotation. Signing uses only the first, so sessions still end. When you first switch from secret, keep it set too: it still decrypts values stored before. Also settable as BETTER_AUTH_SECRETS.
Better Auth146 / 170
Module 18 · Security hardening
Plan for the database leaking
In the database
Without protection
With it
Password hashes
Offline guessing
Slow scrypt; refuse breached passwords
Session tokens
Forging cookies
Cookies also need a signature from the secret; with the bearer plugin, set requireSignature: true
Reset tokens
Resetting anyone's password
verification.storeIdentifier: 'hashed'
Provider tokens
Access to people's GitHub
encryptOAuthTokens, or not keeping them
Two-factor secrets
Generating anyone's codes
Encrypted with the secret by the plugin
Keep the keys apart. Every protection here rests on something that is not in the database: the password itself, the emailed token, or the server secret. A backup, or a copy taken for debugging, then cannot sign anyone in. Leak the secret together with the database, and forged sessions, provider tokens and two-factor secrets all fall at once.
Better Auth147 / 170
Module 18 · Security hardening
The cookie cache, weighed properly
cookieCache.strategy
The cached session is
Readable by the user?
compact (default)
Base64 with an HMAC signature: smallest
Yes
jwt
A standard signed JWT
Yes
jwe
Encrypted and signed
No
Gain: no database read for maxAge seconds (default 300) after each refresh of the cache.
Cost: revoking, banning or changing a role takes up to maxAge to take effect.
Size: everything on the session and user, your extra fields included, rides in a cookie on every request.
Escape hatches: getSession({ query: { disableCookieCache: true } }) for sensitive checks; changing cookieCache.version invalidates every cached copy at once.
Better Auth148 / 170
Module 18 · Security hardening
Deciding whether to cache
QuizInteractive · disabled in the PDF
Which apps benefit most from turning the cookie cache on?
Which apps benefit most from turning the cookie cache on?
Watch out. Never put secrets on the session or user if you use compact or jwt: the cookie is signed, not hidden.
First entry encrypts; the rest only decrypt, so rotation keeps old data readable.
Cookie cache strategies
compact (default), jwt, jwe (encrypted).
Invalidate every cached session at once
Change cookieCache.version.
Module 19: Running it in production: domains, audits, incidents
Better Auth152 / 170
Module 19 · Running it in production: domains, audits, incidents
Three ways to lay out pages and server
Layout
Example
Cookies
One origin
notes.example.com serves pages and /api/auth
Just work: the simplest and safest
Subdomains of one site
notes.example.com and api.example.com
Work with CORS, trusted origins, maybe a shared domain
Different sites
notes.example and auth-host.example.net
Third-party cookies: often blocked by browsers
Tip. If you can, put a reverse proxy in front so the browser sees one origin: the pages, and /api/auth forwarded to the auth server. Most cross-domain problems then disappear.
Better Auth153 / 170
Module 19 · Running it in production: domains, audits, incidents
The cookie gets Domain=example.com, so every subdomain receives it. Pages on other subdomains still need CORS with credentials: true.
QuizInteractive · disabled in the PDF
With Domain=example.com, a forgotten marketing site at promo.example.com is taken over. What can it do?
With Domain=example.com, a forgotten marketing site at promo.example.com is taken over. What can it do?
Better Auth154 / 170
Module 19 · Running it in production: domains, audits, incidents
Different sites: avoid third-party cookies
When the auth server is on a different site, its cookie is a third-party cookie from the page's point of view. It needs SameSite=None; Secure, and many browsers block or partition such cookies anyway. Sign-in that works in one browser silently fails in another.
QuizInteractive · disabled in the PDF
Pages at notes.example must use an auth server at auth-host.example.net. What is the most dependable fix?
Pages at notes.example must use an auth server at auth-host.example.net. What is the most dependable fix?
Better Auth155 / 170
Module 19 · Running it in production: domains, audits, incidents
An audit log records security events
An audit log is a record of who did what and when, kept so you can answer "what happened to this account?" weeks later. Ordinary logs are for debugging and get thrown away; audit entries are kept on purpose.
Record
Why
Sign-ins, failed sign-ins
Spot guessing and takeovers
Password, email and two-factor changes
The moments an account is taken over
Sessions revoked, accounts linked or unlinked
Who still has access
Admin actions: role changes, bans, impersonation
Power used on other people
Account deletion
Required by many privacy rules
Watch out. Each entry: who acted, on whom, what, when, from which IP and browser. Never a password, token, code or cookie.
Better Auth156 / 170
Module 19 · Running it in production: domains, audits, incidents
audit stands for a function that inserts a row into your own audit_log table.
Find the bugInteractive · disabled in the PDF
Which line leaks credentials into the logs?
TypeScript
Better Auth157 / 170
Module 19 · Running it in production: domains, audits, incidents
Signs of an attack
A jump in failed sign-ins, especially across many different emails: credential stuffing.
Many accounts created from one IP range, or with similar emails: bots.
A burst of password-reset requests: someone probing, or a mail system being abused.
Sign-ins to one account from two distant countries minutes apart.
Rate-limit 429s climbing on auth endpoints.
QuizInteractive · disabled in the PDF
Failed sign-ins rise from 200 to 40,000 an hour, spread over 30,000 different emails, about one attempt each. What is most likely happening?
Failed sign-ins rise from 200 to 40,000 an hour, spread over 30,000 different emails, about one attempt each. What is most likely happening?
Better Auth158 / 170
Module 19 · Running it in production: domains, audits, incidents
When something goes wrong, contain first
Put in orderInteractive · disabled in the PDF
Your session-signing secret was committed to a public repository. Put the response in order.
1.Read the audit log for sign-ins and changes since the leak
2.Reset and notify the accounts that show suspicious activity
3.Write down what happened and change the process so it cannot recur
4.Remove the secret from the repository and its history, and check where else it was copied
5.Replace the secret in production, which invalidates every session cookie and email-verification link
Better Auth159 / 170
Module 19 · Running it in production: domains, audits, incidents
Matching the response to the incident
Incident
First move
One account taken over
revokeUserSessions, reset its password, check its linked accounts and two-factor
Secret leaked
Replace it: everyone is signed out
Database copy leaked
Assume hashes will be attacked: force resets for weak or old passwords, tell users
Provider client secret leaked
Rotate it at the provider, then in your environment
A bad deploy breaks sign-in
Roll back; sessions survive if the secret did not change
QuizInteractive · disabled in the PDF
A database backup with password hashes, session rows and unhashed reset tokens leaks. Which steps make sense?
A database backup with password hashes, session rows and unhashed reset tokens leaks. Which steps make sense?
Better Auth160 / 170
Module 19 · Running it in production: domains, audits, incidents
Recap: production
Prefer one origin, with a proxy if needed; subdomains need trusted origins and CORS.
crossSubDomainCookies shares the cookie with every subdomain: keep that set small.
Avoid third-party cookies: proxy, or use bearer tokens.
Keep an audit log of security events, written from hooks, with no secrets in it.
Watch failed sign-ins, sign-ups and resets for the shapes of attacks.
In an incident: contain, clean up, investigate, help the people affected, fix the cause.
FlashcardsInteractive · disabled in the PDF
crossSubDomainCookies
Sets Domain so every subdomain of the site receives the cookie.
Third-party cookie
A cookie from a different site than the page's: often blocked.
Audit log
A kept record of who did what, when, from where.
Shape of credential stuffing
Many accounts, about one failed attempt each.
First move when the secret leaks
Replace it, signing everyone out.
Containment
Stopping further harm before investigating.
Module 20: Mastery challenge
Better Auth161 / 170
Module 20 · Mastery challenge
How to use this module
Each slide is a problem of the kind you meet reviewing real code. Try each one before reading the explanation, and when you get one wrong, go back to the module it names. By the end you should be able to explain not only what is right, but why.
Review a configuration and a route for security holes.
Trace a social sign-in from click to cookie.
Predict what sessions and caches do over time.
Design the auth for a new app.
Respond to an incident.
Better Auth162 / 170
Module 20 · Mastery challenge
Review: a configuration with one dangerous line
Find the bugInteractive · disabled in the PDF
This configuration has one line that lets anyone give themselves power. Which?
TypeScript
Better Auth163 / 170
Module 20 · Mastery challenge
Review: a route that updates a note
Find the bugInteractive · disabled in the PDF
A signed-in user can change anyone's notes with this route. Which line is the hole?
TypeScript
Better Auth164 / 170
Module 20 · Mastery challenge
Trace: Sign in with GitHub, from click to cookie
Put in orderInteractive · disabled in the PDF
Ana, who has never used the app, clicks "Sign in with GitHub". Put what happens in order.
1.GitHub sends the browser to /api/auth/callback/github with a code and the state
2.It creates a session, sets the cookie, and redirects to the callbackURL, which it checked is trusted
3.Better Auth checks the state, then exchanges the code with the verifier and client secret
4.signIn.social posts to /api/auth/sign-in/social; Better Auth stores state and a PKCE verifier
5.The browser goes to GitHub's authorize page; Ana agrees
6.It reads her GitHub profile and verified email, and creates a user and a github account
Better Auth165 / 170
Module 20 · Mastery challenge
Predict: sessions over a week
QuizInteractive · disabled in the PDF
Settings: expiresIn 3 days, updateAge 12 hours, cookie cache on with maxAge 600. Ana signs in Monday 08:00, uses the app Monday 21:00, then an admin bans her Tuesday 10:00 while her tab polls every minute. What is true?
Settings: expiresIn 3 days, updateAge 12 hours, cookie cache on with maxAge 600. Ana signs in Monday 08:00, uses the app Monday 21:00, then an admin bans her Tuesday 10:00 while her tab polls every minute. What is true?
Better Auth166 / 170
Module 20 · Mastery challenge
Build: a protected route that keeps sessions alive
Fill the blanksInteractive · disabled in the PDF
Read the session in your own route, pass any refreshed cookie on, and refuse anonymous callers.
TypeScript
1app.get('/notes',async(c)=>{2const{ headers, response }=await auth.api.getSession({3headers: c.req.raw.headers,4:true5})6for(const cookie of headers.())7 c.header('Set-Cookie', cookie,{append:true})8if(!response)return c.json({error:'Sign in first'},)9return c.json(awaitnotesOf(response..id))10})
Better Auth167 / 170
Module 20 · Mastery challenge
Design: an invite-only notes app for one company
QuizInteractive · disabled in the PDF
A company wants its staff, and only its staff, to use a notes app. Staff use Google accounts on company.example. Which choices fit?
A company wants its staff, and only its staff, to use a notes app. Staff use Google accounts on company.example. Which choices fit?
Better Auth168 / 170
Module 20 · Mastery challenge
Respond: a takeover report
QuizInteractive · disabled in the PDF
Ben says someone changed his email address and he can no longer sign in. The audit log shows a sign-in from a new country, then an email change, then GitHub linked. What do you do first?
Ben says someone changed his email address and he can no longer sign in. The audit log shows a sign-in from a new country, then an email change, then GitHub linked. What do you do first?
Better Auth169 / 170
Module 20 · Mastery challenge
Your final project
Build the notes app end to endInteractive · disabled in the PDF
Better Auth170 / 170
Module 20 · Mastery challenge
Recap: what an expert keeps in mind
Authentication says who; authorization, checked on every request, says what.
The session cookie is a password: HttpOnly, Secure, SameSite, short-lived, revocable.
Never reveal who has an account, in words or timing.
Trust only verified emails, trusted origins, and fields callers cannot set.
Layer defences against guessing, and keep secrets apart from the database.
Test the refusals, log the events, and contain first when something breaks.
FlashcardsInteractive · disabled in the PDF
The one-line review question for any route
Is the owner (or permission) in the check, not just the session?
Why check state before the code exchange?
A forged callback stops before anything is trusted.
What makes a field safe to accept from callers?
Nothing anyone sends to it can grant access.
Cookie cache and bans
A ban can take up to maxAge to bite unless the cache is bypassed.
Containing a takeover
Revoke sessions and remove every way in the attacker added.
Where users are created, whatever the path
databaseHooks.user.create.
Answer key
1. Who you are, and what you may do · Authentication is who you are; authorization is what you may do · QuizAna is signed in and tries to open a note that belongs to Ben. The app refuses. Which check refused her?B. AuthorizationThe app knows exactly who she is, so authentication worked. What failed is the rule "only the owner may read a note", which is authorization. Mixing the two up is a classic source of security holes: being signed in is not the same as being allowed.
1. Who you are, and what you may do · Credentials prove who you are · QuizWhich of these is really two-factor sign-in?B. A password and a 6-digit code from an app on your phoneA password and a phone code are two different factors: something you know and something you have. A security question or a second password is just more "something you know", which leaks the same ways. An email address is not a secret at all, so it proves nothing.
1. Who you are, and what you may do · The journeys every app with accounts needs · Put in orderPut Ana's first visit to the notes app in order.1. She fills in the sign-up form with her email and a password 2. The app saves a new user and sends her a confirmation email 3. She clicks the link in the email 4. The app marks her email as confirmed and signs her in 5. She writes her first noteSign-up makes the user; the email link proves the address is really hers; only then is she signed in and working. Later modules build each step with Better Auth.
1. Who you are, and what you may do · Checks that matter happen on the server · QuizThe notes page hides the "Delete" button on notes that are not yours. Is that enough to stop someone deleting other people's notes?B. No: anyone can send the delete request directly; the server must check ownershipHiding a button is good design, but it is not security. A visitor can open the browser tools or use a command-line tool and send any request they like. Only the server, which the visitor cannot change, can be trusted to say no.
2. HTTP forgets you, cookies remind it · Each request starts from nothing · QuizAna signs in, then clicks "My notes". Without cookies or tokens, what does the server know about the second request?B. Nothing: each HTTP request stands aloneThe server has no memory of earlier requests unless the request itself carries something. IP addresses are shared by many people (a whole office or a phone network) and change often, so they cannot identify anyone.
2. HTTP forgets you, cookies remind it · Cookie settings decide who can see and send it · QuizAn attacker manages to run their script on the notes page. Which setting stops that script reading the session cookie?B. HttpOnlyHttpOnly hides the cookie from document.cookie and every other script API; the browser still sends it with requests. Secure is about HTTPS and SameSite about other sites, so neither stops a script on your own page. Better Auth sets HttpOnly on its session cookie by default.
2. HTTP forgets you, cookies remind it · SameSite decides what other sites can trigger · QuizAna is signed in to the notes app (session cookie SameSite=Lax). She visits evil.example.net, whose page silently submits a form that POSTs to https://notes.example.com/notes/delete-all. Is her cookie sent?B. No: with Lax, a cross-site POST does not carry the cookieThis is a cross-site request forgery attempt, met again in Module 5. Lax withholds the cookie from cross-site form posts and scripted requests, so the notes server sees an anonymous request and refuses it. None would have sent it; that is why None needs extra protection.
3. Sessions and tokens · The token must be long and random · QuizAn app makes session tokens from the user's id and the time they signed in. What is wrong?B. Someone who knows or guesses the id and time can build the token and sign in as that userUnique is not the same as unguessable. Ids leak and sign-in times can be narrowed to a few thousand seconds, so an attacker can simply try them all. Better Auth makes tokens from a secure random source, so there is nothing to predict.
3. Sessions and tokens · A signature proves the server made it · QuizA copy of the notes app's database leaks, session tables included. Better Auth signs its cookies. Can the thief paste a token from the database into a cookie and be signed in?B. No: the cookie also needs a signature, made with a secret that is not in the databaseThe cookie must carry the token and a valid signature, and the signing secret lives in the server's configuration, not in the database. That is why the secret and the database must be kept apart: leak both, and every session is open.
3. Sessions and tokens · Looking it up costs a read; not looking it up costs control · QuizAna's laptop is stolen. She signs out everywhere from her phone. Which kind of credential on the laptop stops working immediately?A. A database sessionSigning out everywhere deletes her session rows, and the next request from the laptop finds nothing. A self-contained token is checked only by its signature and expiry, so it keeps working until it expires, unless the server also keeps a block list, which is a lookup again.
3. Sessions and tokens · Where the browser keeps it matters · QuizFor a website, why do most auth libraries, Better Auth included, prefer an HttpOnly cookie to localStorage?B. A script injected into the page cannot read an HttpOnly cookie, and the CSRF risk is handled by SameSite and origin checksBoth risks are real, but they are not equal. A stolen token from localStorage works anywhere, for as long as it lives. A cookie that scripts cannot read can only be used from the victim's browser, and SameSite plus origin checks stop other sites from using it.
4. Storing passwords safely · Never keep the password itself · QuizWhy is a plain-text password leak worse for users than for the notes app itself?B. People reuse passwords, so attackers try them on email, banks and shopsThe notes might be harmless, but the same email and password often open far more valuable accounts. Trying leaked pairs on other sites is called credential stuffing, and it is one of the most common attacks there is (Module 5).
4. Storing passwords safely · Hashing is not encryption · QuizA site emails you your old password when you click "Forgot password". What does that tell you?B. They store passwords in a form they can read back: plain text or reversible encryptionWith hashing, nobody can recover a password, not even the site. That is why good apps send a link to choose a new password instead (Module 10).
4. Storing passwords safely · A salt makes every hash unique · QuizThe salt is stored right next to the hash. Does it need to be secret to work?B. No: its job is to make each hash unique, so precomputed lists and one-guess-for-everyone stop workingA known salt still forces the attacker to attack each user separately, from scratch. A secret value kept outside the database, called a pepper, adds protection on top, but that is a different thing.
4. Storing passwords safely · Password hashes are slow on purpose · QuizAn attacker with a leaked database tries 10 billion guesses per user. A fast hash allows 1 billion guesses a second; a slow one about 10. Roughly how long per user does each take?A. 10 seconds, and about 30 years10 billion ÷ 1 billion per second is 10 seconds. 10 billion ÷ 10 per second is a billion seconds, about 30 years. Slowness turns a leak from a disaster into an expensive dead end, at least for passwords that are not on a list of common ones.
4. Storing passwords safely · Checking a password without giving anything away · Put in orderPut the steps of a careful password check in order.1. Find the user by the email typed 2. Read their stored salt and hash 3. Hash the typed password with the same salt and settings 4. Compare the two hashes in constant time 5. Answer the same way for a wrong password and an unknown email"Constant time" means the comparison takes as long whether the first character differs or the last, so timing reveals nothing. Answering the same for an unknown email stops attackers learning who has an account. Better Auth even hashes a dummy password for unknown emails, so both take as long.
5. How accounts get attacked · Phishing: a convincing fake · QuizWhich defence still works if Ana types her password into a perfect fake page?B. A passkey or second factor the fake page cannot use on the real siteOnce the attacker has the password, its length and how it is hashed no longer matter. A passkey is tied to the real site by the browser, so the fake page gets nothing usable; a second factor at least forces the attacker to steal that too.
5. How accounts get attacked · Credential stuffing and brute force: guessing at scale · QuizThe notes app allows 5 wrong passwords per account per 15 minutes. An attacker has 100,000 leaked email and password pairs and tries each once. Does the per-account limit slow them down?B. No: each account sees only one attempt, so no account limit is ever reachedStuffing spreads attempts thinly, one per account. Defences have to look at other things: attempts per IP address, a check that a human is there, refusing passwords known to be leaked, and second factors. Module 16 layers them.
5. How accounts get attacked · XSS: the attacker's script on your page · Find the bugThis shows a note on the page. Which line lets a note run code?Line 5: body.textContent = note.bodyinnerHTML parses the text as HTML, so markup in the note becomes real elements, including ones that run script (an <img onerror=…> is enough). textContent shows it as plain text. Frameworks like React escape text by default; the danger returns when you bypass that.
5. How accounts get attacked · Session theft: copying the key · QuizWhich of these limit what a stolen session can do?A. Sessions that expire when unused; B. A page listing your devices, with "sign out" next to each; C. Asking for the password again before changing the email or deleting the accountExpiry and remote sign-out shorten how long a stolen session lives; asking for the password before dangerous actions stops the thief taking the account over for good. Putting a token in a URL does the opposite: URLs end up in history, logs and shared links.
6. Installing Better Auth · A secret and a base URL, from the environment · QuizIn production, the secret is replaced with a new one. What happens?B. Every signed-in browser is signed out, and emailed verification links already sent stop workingCookies and verification links carry signatures made with the old secret, so they no longer check out. Passwords are hashed without it, so they keep working. Module 18 shows how to rotate a secret more gently.
6. Installing Better Auth · Four tables: user, session, account, verification · QuizWhere does Better Auth keep the hash of Ana's password?B. On an account row whose providerId is credentialEach way of signing in is an account. Email and password is the one called credential, with the hash in its password column. When Ana later adds GitHub, that is a second account on the same user, and the user row never changes.
6. Installing Better Auth · With an ORM, the tables are part of your schema · Fill the blanksPoint Better Auth at a Drizzle database on SQLite.1: drizzleAdapter, 2: sqliteprovider tells the adapter which SQL dialect it speaks: sqlite, pg or mysql. schema hands it your table definitions; npx auth generate can write the Better Auth ones into your schema file to start from.
6. Installing Better Auth · Two ways in: the handler and auth.api · QuizYour own GET /notes route needs to know who is asking. Which do you use?B. auth.api.getSession({ headers: request.headers })auth.api.getSession reads the session cookie from the headers you pass, checks its signature and the session row, and returns the user, or null. Reading the table by hand skips the signature check and the expiry rules. Trusting an id from the browser is no check at all.
7. Serving it from a Hono server · Try it with curl · QuizWith only emailAndPassword: { enabled: true }, what comes back from that sign-up?B. The new user and a Set-Cookie header with a session: she is signed inBy default autoSignIn is on, so signing up also signs in: the body holds the user and a session token, and Set-Cookie sets better-auth.session_token. name is a required field in Better Auth. Module 9 explains why you may want to turn autoSignIn off.
7. Serving it from a Hono server · Your routes ask auth.api who is signed in · Fill the blanksAnswer GET /notes only for a signed-in person, with their own notes.1: getSession, 2: headers, 3: 401, 4: usergetSession reads the cookie from the request headers and returns { session, user } or null. No session means not authenticated: 401. The notes are looked up by the id Better Auth confirmed, never by an id the browser sent.
7. Serving it from a Hono server · Pages on another origin need CORS · QuizFor signed-in requests from https://notes.example.com to work, which are needed?A. CORS on the server naming https://notes.example.com, with credentials: true; B. https://notes.example.com in Better Auth's trustedOriginsBrowsers only share a response that carries cookies with the exact origin CORS names, and refuse * together with credentials. Separately, Better Auth checks the origin against trustedOrigins. Turning that check off would let any site make signed-in requests. Keeping pages and API on one origin avoids all of this.
7. Serving it from a Hono server · When nothing answers, check the path · Find the bugEvery request to sign in answers 404. Which line is wrong?Line 6: app.on(['GET', 'POST'], '/api/auth/*', (c) =>Better Auth serves its endpoints under its basePath, /api/auth by default, and the client calls those paths. The Hono route must match. Either mount it at /api/auth/* or set basePath to /auth in the options.
8. The client in the browser · Every call answers with data or an error · QuizA sign-in with a wrong password runs this. What happens?B. The page goes to /notes anyway, because no error was thrownThe refusal comes back as error in the result; nothing is thrown, so the catch never runs and the next line sends Ana to /notes, where she is not signed in. Read error from the result instead of relying on try.
8. The client in the browser · Who is signed in, and signing out · Fill the blanksShow who is signed in, and sign out when the button is clicked.1: getSession, 2: user, 3: signOutgetSession asks the server, which checks the cookie, so the answer is trustworthy for display. signOut deletes the session on the server and clears the cookie. Showing a name in the page is fine; deciding what someone may do still happens on the server.
8. The client in the browser · In React, useSession keeps the page up to date · QuizWhy check isPending before checking session?B. While the first answer is on its way, session is empty; without the check, signed-in people would briefly see "Sign in"The hook starts before the server has answered. isPending is true until then, so you can show a placeholder instead of the signed-out state flashing up.
8. The client in the browser · Reading an error · QuizA teammate wants to enforce "at least 12 characters" by checking the password length in the page before calling signUp.email. Is that enough?B. No: the page check is a convenience; set minPasswordLength on the server, which anyone calling the endpoint directly cannot skipChecking in the page gives fast feedback, which is good. But anyone can call /api/auth/sign-up/email with curl. Rules that matter must live in the server configuration, where Better Auth enforces them.
9. Email and password, properly · Sign-up that doesn't say who has an account · QuizWith the defaults (autoSignIn: true, no verification required), what does signing up with an email that already has an account do?B. An error, USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL, which tells the caller the email is registeredWhen sign-up would sign the person straight in, a pretend success cannot work: there is no session to hand over. So Better Auth refuses, and that refusal reveals the account exists. Turning autoSignIn off and confirming emails first is what makes the identical answer possible.
9. Email and password, properly · Calling it from the server, and catching refusals · Find the bugWhen the database is briefly unreachable, people are told their password is wrong. Which line causes it?Line 10: } catch (error) { if (!(error instanceof APIError)) throw errorA bare catch turns every failure into "wrong password": outages are hidden from monitoring and people are confused for nothing. With asResponse: true, a wrong password already comes back as a 401Response, cookies included, ready to send back, so what reaches this catch is mostly real failures. Handle APIError (a refusal, such as one thrown by a hook) and rethrow the rest, which becomes a 500.
9. Email and password, properly · Bringing your own password hash · QuizYour stored hashes include their settings, like argon2id$m=65536,t=3$salt$hash. You raise the cost for new hashes. What happens to existing passwords?B. They keep working: verify reads each hash's own settingsA hash that names its parameters can always be checked with them. New hashes use the new cost. To upgrade old ones, rehash right after a successful sign-in, the only moment you have the password; Better Auth does not do that by itself.
9. Email and password, properly · Changing a password signs other devices out · Fill the blanksLet Ana change her password from the settings page and sign out her other devices.1: changePassword, 2: revokeOtherSessionschangePassword needs a signed-in session and the current password, so a borrowed, unlocked laptop is not enough to take the account over. With revokeOtherSessions, every other device is signed out. A wrong current password returns the INVALID_PASSWORD code.
10. Emailed links: verification, resets and magic links · Send without making the answer wait · QuizWhy does it matter for security, not just speed, that the reply does not wait for the email to be sent?B. Otherwise answers that send an email take longer than ones that do not, revealing which emails have accountsCalling an email service takes hundreds of milliseconds. If a reset request for a registered email waits for it and one for an unknown email does not, the response time tells an attacker which is which, even with identical words.
10. Emailed links: verification, resets and magic links · Require it, or gate what it unlocks · QuizNotes can be shared "with anyone at @company.example". Why must that rule look only at confirmed emails?B. Anyone could sign up as someone@company.example without owning that inbox and read shared notesUntil the email is confirmed, it is only a claim. Anything that grants access because of an email (sharing, invitations, purchases, linking accounts) must check emailVerified first. Better Auth's account linking follows the same rule (Module 12).
10. Emailed links: verification, resets and magic links · Choosing a new password, end to end · Put in orderPut a password reset in order.1. Ana asks for a link; the page says "check your email" whether or not the email exists 2. Better Auth stores a random token and calls sendResetPassword 3. Ana opens the link; Better Auth checks the token and sends her on to /reset-password?token=… 4. She types a new password; the page calls resetPassword with it and the token 5. Better Auth saves the new hash and deletes the token so it cannot be used againThe request answers the same way for any email, so it reveals nothing. The token is single-use and expires in an hour by default (resetPasswordTokenExpiresIn, in seconds).
10. Emailed links: verification, resets and magic links · After a reset, assume the old password was known · QuizWithout revokeSessionsOnPasswordReset: true, what happens to a thief's session after Ana resets her password?B. It keeps working: the option is off by defaultA reset changes the password, not the sessions. If someone was already signed in as Ana, they stay signed in unless sessions are revoked. The "password changed" email is how a real owner learns in time if someone else did it.
10. Emailed links: verification, resets and magic links · Magic links: sign in by email alone · Fill the blanksAdd magic-link sign-in on the server and the client.1: magicLink, 2: magicLinkClient, 3: magicLinkPlugins come in pairs: the server half adds endpoints, the client half adds typed methods. The link works once and expires after 5 minutes by default (expiresIn). By default it also signs up new emails; disableSignUp: true allows existing users only.
11. Sessions in depth · expiresIn and updateAge make a sliding window · QuizWith the defaults (7 days, 1 day), Ana signs in Monday at 09:00. She uses the app Monday at 15:00 and Tuesday at 10:00, then not again. When does her session expire?C. Next Tuesday at 10:00At 15:00 the session was extended only 6 hours earlier (at sign-in), less than updateAge, so nothing changes. On Tuesday at 10:00 it is 25 hours since, so expiresAt moves to now plus 7 days: next Tuesday at 10:00. A shorter updateAge follows last use more closely, at the cost of more database writes.
11. Sessions in depth · A refreshed session comes with a new cookie · QuizA middleware calls getSession without passing refreshed cookies on. Sessions last 7 days. What do people notice?B. They are signed out about 7 days after signing in, however often they visitThe row's expiresAt moves forward, but the browser keeps the cookie from sign-in, with its original lifetime. When that runs out, the browser stops sending it. Bugs like this are easy to miss in testing because they take a week to appear.
11. Sessions in depth · A "where you are signed in" page · Fill the blanksList Ana's sessions and sign out every other device.1: listSessions, 2: ipAddress, 3: revokeOtherSessionsEach session records the browser (userAgent) and ipAddress it was made from, which is enough to say "Firefox on a laptop". Revoking deletes the rows, so those devices are signed out on their next request. listSessions needs a fresh session (previous slide), so the page must ask anyone who signed in over a day ago to sign in again; the revoke calls only need a valid one.
11. Sessions in depth · The session cookie, as Better Auth sets it · QuizWhat does the __Secure- prefix on the cookie name add?B. The browser only accepts the cookie if it is set over HTTPS with Secure, so a plain-HTTP page cannot plant or overwrite itCookie name prefixes are enforced by browsers. __Secure- requires the Secure attribute and an HTTPS origin. __Host- is stricter still (Module 18).
11. Sessions in depth · The cookie cache trades revocation for speed · QuizWith a 5-minute cookie cache, Ana signs out a stolen laptop from her phone. How long can the laptop keep working?B. Up to 5 minutes, until its cached copy expiresWhile the cached cookie is valid, getSession does not look at the database, so it cannot see the row is gone. For sensitive actions, ask for a fresh check with getSession({ query: { disableCookieCache: true } }). Module 18 weighs when the cache is worth it.
12. Social sign-in with OAuth, and linking accounts · socialProviders, and a button that starts it · Fill the blanksStart GitHub sign-in from the page, landing on the notes afterwards.1: signIn, 2: social, 3: githubThe client asks the server to start the flow and sends the browser to GitHub. callbackURL is where Ana lands at the end; Better Auth only accepts it on a trusted origin, so nobody can turn the flow into a redirect to their own site.
12. Social sign-in with OAuth, and linking accounts · State and PKCE close two different holes · QuizA malicious browser extension reads the callback address, code included. With PKCE, what can it do with the code?B. Nothing useful: the exchange also needs the verifier, which never left the serverOnly the hash of the verifier travelled through the browser. The provider exchanges the code only for whoever presents the matching verifier, which Better Auth kept on the server. Better Auth makes and checks state and PKCE for you; options that skip these checks exist for unusual setups, so leave them alone.
12. Social sign-in with OAuth, and linking accounts · One user, one account per way in · QuizAna changes the email on her GitHub account. Next time she signs in with GitHub, how does Better Auth find her?B. By the account row with providerIdgithub and her GitHub idAccounts are found by provider and the provider's own stable id for the person (accountId), not by email. Emails change and get reused; a provider's id does not. The email only matters the first time, when Better Auth decides whether to link.
12. Social sign-in with OAuth, and linking accounts · Linking on first sign-in, only with verified emails · QuizAn attacker signs up with a password using Ana's email, never confirming it. Later Ana clicks "Sign in with Google". Why does Better Auth refuse to link by default?B. Linking would put Ana's Google sign-in into an account whose password the attacker knowsThis is a pre-hijacking attack: create the account first, wait for the victim. After linking, the attacker could sign in with their password and read everything Ana writes. requireLocalEmailVerified (on by default, and deprecated in 1.7 because it is to become permanent) refuses to link into an account whose email was never proved.
13. Protecting routes, and roles · Authorization: check the row is theirs · QuizWhy answer 404, not 403, for a note that exists but belongs to someone else?B. A 403 would confirm the id exists; 404 reveals nothing about other people's notesFrom Ana's point of view, Ben's note simply isn't one of hers. Answering 403 lets anyone probe which ids exist. With the owner in the query, 404 is the natural result.
13. Protecting routes, and roles · Your own routes need their own CSRF check · QuizThe session cookie is SameSite=Lax. Why add an origin check to your own routes as well?B. Defence in depth: SameSite treats all subdomains as the same site, older browsers differ, and a GET that changes data slips through LaxSameSite is about sites, so a compromised blog.example.com can still send requests to notes.example.com with cookies. Checking the exact origin closes that. And never let a GET change data: links and images can trigger it.
13. Protecting routes, and roles · What an admin can do · QuizWhy is impersonation a power to use carefully, even for honest admins?B. The admin sees and can change everything the user can, so it needs a record and a short life; Better Auth marks those sessions with impersonatedByImpersonation sessions are real sessions as that user, marked with the admin's id and short-lived (impersonationSessionDuration, 1 hour by default). Log every one. By default admins cannot impersonate other admins.
13. Protecting routes, and roles · Permissions of your own · Fill the blanksIn a route, let only people allowed to delete any note do so.1: userHasPermission, 2: note, 3: 403Check permissions, not role names: tomorrow's support role can get delete-any without touching this route. 403 is right here: the person is known, and simply not allowed.
14. Plugins: two-step sign-in, passkeys and teams · Adding a plugin is a small migration · Put in orderAdd the twoFactor plugin to a running app. Put the steps in order.1. Add twoFactor() to the server's plugins and twoFactorClient() to the client's 2. Run the migration: the tool reads the plugin's schema and adds its table and column 3. Build the pages and try them locally: turn it on, then sign in with a code 4. Migrate the production database 5. Deploy the new codeThe plugin knows its own schema, so it comes first and the command-line tool reads it from your configuration. Trying the pages needs the new table; so does production, before code that uses it goes live, or every request that touches it fails.
14. Plugins: two-step sign-in, passkeys and teams · Signing in with a second factor · QuizA 6-digit code has a million possible values. Why must code checks be rate-limited as hard as passwords?B. Without a limit, a script can try thousands of codes a second and will often hit the current oneA code is valid for a short window, but a million values is small for a computer. Strict limits on attempts make guessing hopeless; the plugin registers limits for its endpoints, and Module 16 covers making limits effective.
14. Plugins: two-step sign-in, passkeys and teams · Backup codes are passwords too · QuizAna loses her phone and her backup codes. What is the safest recovery an app can offer?B. A recovery process that proves identity in another strong way, with a waiting period and notices to the account's emailIf "I lost my phone" turns two-factor off immediately, attackers will say it too. A slow, noisy recovery (delays, emails to the owner, staff checks for valuable accounts) keeps the second factor meaningful.
14. Plugins: two-step sign-in, passkeys and teams · Organizations: shared notebooks for teams · QuizBen's session has an active organization. A route lists that team's notebooks. What must it still check?B. That Ben is still a member, with a role allowed to read them, at the time of the requestMembership can change after the session was made: Ben may have been removed an hour ago. Check the member row (or use the plugin's permission checks) on every request that reads or changes team data.
15. Hooks and fields of your own · Practice: close sign-in for suspended emails · Fill the blanksRefuse email sign-in for addresses on a suspended list, before Better Auth checks the password.1: before, 2: path, 3: APIErrorA before hook runs before the endpoint, so a refused request never reaches the password check. For real bans, the admin plugin's banUser already does this and ends sessions too; a hook is for rules of your own.
15. Hooks and fields of your own · An after hook reacts to what happened · QuizThis after hook checks ctx.path.startsWith('/sign-in'). Does it email Ana when she signs in with GitHub?B. No: the session is created at the end, on /callback/github, which this check skips/sign-in/social only starts the flow and returns GitHub's address; the session is made later at /callback/:id, and magic links finish at /magic-link/verify. Checking newSession alone, on any path, catches every way in.
15. Hooks and fields of your own · databaseHooks run around writes · QuizEvery new user should get a welcome note, however they signed up: password, GitHub, Google or a magic link. Where does it belong?B. databaseHooks.user.create.afterAn endpoint hook sees only the paths you list, and each way in creates users on a different path. A database hook fires whenever Better Auth creates a user, whichever endpoint or plugin did it. It does not fire for rows you write yourself without Better Auth.
15. Hooks and fields of your own · Extra fields on the user · QuizA teammate adds plan: { type: 'string', defaultValue: 'free', input: true } and paid features check user.plan === 'pro'. What can anyone do?B. Sign up, or call updateUser, with plan: 'pro' and get paid features freeinput: true accepts the field from the request. Fields that grant anything need input: false, set only by your server code (for a plan, from the payment provider's confirmation). Treat every input: true field as untrusted input.
16. Rate limits, bots and breached passwords · Counts must be shared by every server · QuizThe notes app runs on four servers behind a load balancer. Why is the default storage: 'memory' a weak limit?B. Each server counts on its own, so an attacker gets four times the limit, more on serverless platforms with many short-lived copiesIn-memory counters see only their own server's share of the traffic and vanish on restart. storage: 'database' (which needs a rateLimit table: rerun the migration) or 'secondary-storage' (such as Redis, and the default once a secondaryStorage is configured) keeps one shared count. Better Auth updates it in one atomic step, so two requests cannot both slip under the limit.
16. Rate limits, bots and breached passwords · Know which IP you are limiting · Find the bugThis per-IP limit on a custom route runs behind Cloudflare. Which line lets one attacker look like thousands of addresses?Line 2: c.req.header('cf-connecting-ip') ??The first entry of X-Forwarded-For is whatever the client wrote, so a script sends a random one each time and never hits the limit. Use the header your edge sets: CF-Connecting-IP on Cloudflare. For IPv6, Better Auth groups addresses by their /64 (ipv6Subnet), because one person often has a whole block.
16. Rate limits, bots and breached passwords · Lockouts can be turned against the account · QuizAn attacker sends 10 wrong passwords for Ana's email every 15 minutes. What keeps Ana able to get in?B. Short locks with a clear message, a reset link that still works, and a successful reset clearing the countKeep account locks short and recoverable: say plainly what happened and offer a reset link, which proves inbox access and clears the count. The attacker also burns attempts from their own addresses, which the per-IP limit and a captcha make expensive.
16. Rate limits, bots and breached passwords · A captcha checks a person is there · Fill the blanksRequire Turnstile on sign-up, sign-in and reset requests.1: captcha, 2: cloudflare-turnstile, 3: x-captcha-responseBy default the plugin guards /sign-up/email, /sign-in/email and /request-password-reset; endpoints changes the list. It also supports Google reCAPTCHA and hCaptcha. If the provider cannot be reached, it fails closed: the request is refused.
16. Rate limits, bots and breached passwords · Refuse passwords that are already leaked · QuizThe plugin asks the Have I Been Pwned service about each new password. What does it actually send?C. The first 5 characters of the password's SHA-1 hash; it compares the rest itselfThis is called k-anonymity: the service returns every leaked hash starting with those 5 characters, hundreds of them, and the plugin looks for a match locally. The service never learns the password or even which hash was asked about. If the service is down, the plugin refuses the password change rather than skipping the check.
17. Testing auth flows · Carry the cookie to the next request · Fill the blanksAfter signing in a confirmed user, use the session cookie to ask who is signed in.1: getSetCookie, 2: cookie, 3: userA browser keeps only the name=value part of each Set-Cookie and sends them back joined with ; . Tests do the same by hand. Hono's app.request(path, init) works the same way for your own routes.
17. Testing auth flows · Catch emailed links and follow them · Put in orderPut the steps of a "reset link works only once" test in order.1. Sign up a confirmed user (or create one directly) 2. POST /request-password-reset with their email 3. Read the last entry of sent and take the token from its URL 4. POST /reset-password with the token and a new password: expect success 5. POST /reset-password again with the same token and another password: expect a refusal 6. Sign in with the first new password: expect success, so the refused reuse changed nothingCapturing emails turns a flow that needs an inbox into a few lines. The second use of the token is the assertion that matters: single-use is a security property, and a configuration change could break it silently.
17. Testing auth flows · Test the refusals hardest · QuizA rate-limit test passes in CI but limits never seem to trigger. What is the likely reason?B. The limiter is on only in production by default, so the test must enable it explicitlyIn tests, the environment is not production, so the limiter is off unless rateLimit.enabled is true. A test that never checks the limiter can still pass: make sure it asserts the 429.
18. Security hardening · Pin the session cookie to one host · QuizIf useSecureCookies were left on with that name, what would the browser receive?B. __Secure-__Host-notes_session: browsers check only the start of the name, so the __Host- rules no longer applyPrefixes work only at the very start of the name. With __Secure- in front, the browser enforces Secure but would accept a Domain, so the guarantee that only this exact host sets the cookie is gone. Test it: look at the real Set-Cookie in the browser tools.
18. Security hardening · Trusted origins guard redirects and requests · QuizA team sets advanced.disableOriginCheck: true because a preview deployment's sign-in failed. What did they open?B. Open redirects: a real sign-in link can now send people, freshly signed in, to any site an attacker namesWithout the check, callbackURL=https://evil.example.net/… is honoured, so a real sign-in on the real domain ends on the attacker's page: a perfect phishing setup. In 1.7 it also switches off the CSRF origin check, unless disableCSRFCheck: false is set. The fix is to add the preview origins to trustedOrigins. disableCSRFCheck and skipStateCookieCheck belong on the same never-in-production list.
18. Security hardening · Deciding whether to cache · QuizWhich apps benefit most from turning the cookie cache on?A. A busy app whose database is far from its servers, where a few minutes' delay on revocation is acceptableThe cache pays off when session reads dominate and are slow, such as edge servers reading a distant database. Where revocation must be immediate, leave it off, or bypass it on every sensitive route. One indexed lookup per request is cheap for most apps.
19. Running it in production: domains, audits, incidents · Sharing a session across subdomains · QuizWith Domain=example.com, a forgotten marketing site at promo.example.com is taken over. What can it do?B. Receive every visitor's session cookie, and set cookies that shadow the real onesA shared-domain cookie goes to every subdomain, however neglected. Share only when you must, keep the list of subdomains small and maintained, and remember a __Host- cookie cannot have a Domain, so the two choices exclude each other.
19. Running it in production: domains, audits, incidents · Different sites: avoid third-party cookies · QuizPages at notes.example must use an auth server at auth-host.example.net. What is the most dependable fix?B. Proxy /api/auth through notes.example, so the cookie is first-party; or use bearer tokens instead of cookiesA proxy makes the cookie belong to the page's own site, which every browser accepts. The bearer plugin is the other option for apps that cannot use cookies, at the cost of storing a token in code. localStorage exposes the token to any injected script.
19. Running it in production: domains, audits, incidents · Writing audit entries from hooks · Find the bugWhich line leaks credentials into the logs?Line 4: console.log('sign-in attempt', { email: ctx.body?.email })ctx.body holds the password. Every password typed, right or wrong, now sits in the logs next to its email: wrong ones are often a letter away from right ones, or someone's password elsewhere. Log outcomes and identifiers, never secrets.
19. Running it in production: domains, audits, incidents · Signs of an attack · QuizFailed sign-ins rise from 200 to 40,000 an hour, spread over 30,000 different emails, about one attempt each. What is most likely happening?B. Credential stuffing: leaked pairs from another site tried once per accountOne attempt per account across many accounts is the signature of stuffing. Per-account limits will not trigger. Tighten per-IP limits, turn on the captcha, watch for the successes among the failures, and force resets for accounts that were broken into.
19. Running it in production: domains, audits, incidents · When something goes wrong, contain first · Put in orderYour session-signing secret was committed to a public repository. Put the response in order.1. Replace the secret in production, which invalidates every session cookie and email-verification link 2. Remove the secret from the repository and its history, and check where else it was copied 3. Read the audit log for sign-ins and changes since the leak 4. Reset and notify the accounts that show suspicious activity 5. Write down what happened and change the process so it cannot recurContainment comes first: until the secret changes, anyone with it can forge session cookies. Cleaning up comes next (deleting the commit alone does not help: copies exist). Then find out what was done with it, help the people affected, and fix the cause.
19. Running it in production: domains, audits, incidents · Matching the response to the incident · QuizA database backup with password hashes, session rows and unhashed reset tokens leaks. Which steps make sense?A. Invalidate pending reset tokens, by deleting verification rows; B. Tell users, and require password changes, starting with accounts whose hashes are oldest or weakest; D. Turn on storeIdentifier: 'hashed' so future tokens are not exposed the same wayUnhashed reset tokens in a leak are live keys to accounts until they expire, so delete them. Slow hashes buy time, not immunity: common passwords will fall. Session tokens alone are useless as cookies without the signing secret (but the bearer plugin accepts them raw unless requireSignature: true). And fix the gap that made the leak worse.
20. Mastery challenge · Review: a configuration with one dangerous line · Find the bugThis configuration has one line that lets anyone give themselves power. Which?Line 11: input: false }canModerate grants power, and input: true lets anyone send canModerate: true at sign-up or through updateUser. Fields that grant anything must be input: false, set only by trusted server code; better still, use the admin plugin's roles and permissions (Modules 13 and 15).
20. Mastery challenge · Review: a route that updates a note · Find the bugA signed-in user can change anyone's notes with this route. Which line is the hole?Line 6: .where(and(eq(notes.id, c.req.param('id')), eq(notes.ownerId, user.id)))requireUser proves who is asking (authentication); nothing checks the note is theirs (authorization). Put the owner in the where, so someone else's note is simply not found and nothing changes. user was read and never used: a hint worth noticing in review (Module 13).
20. Mastery challenge · Trace: Sign in with GitHub, from click to cookie · Put in orderAna, who has never used the app, clicks "Sign in with GitHub". Put what happens in order.1. signIn.social posts to /api/auth/sign-in/social; Better Auth stores state and a PKCE verifier 2. The browser goes to GitHub's authorize page; Ana agrees 3. GitHub sends the browser to /api/auth/callback/github with a code and the state 4. Better Auth checks the state, then exchanges the code with the verifier and client secret 5. It reads her GitHub profile and verified email, and creates a user and a github account 6. It creates a session, sets the cookie, and redirects to the callbackURL, which it checked is trustedState is checked before anything else, so a forged callback stops at once. The exchange needs the verifier (PKCE) and the secret, both server-side. Because the email is new, no linking decision is needed; had a password account with that email existed, the verified-email rules of Module 12 would decide.
20. Mastery challenge · Predict: sessions over a week · QuizSettings: expiresIn 3 days, updateAge 12 hours, cookie cache on with maxAge 600. Ana signs in Monday 08:00, uses the app Monday 21:00, then an admin bans her Tuesday 10:00 while her tab polls every minute. What is true?A. Monday 21:00 extended her session to Thursday 21:00; B. Her requests may keep succeeding for up to 10 minutes after the banAt 21:00, 13 hours had passed since sign-in, more than updateAge, so expiresAt became now plus 3 days: Thursday 21:00. A ban deletes her sessions, but a cached session cookie is trusted for up to maxAge (600 seconds) without asking the database (Modules 11 and 18). Use disableCookieCache on routes where that matters.
20. Mastery challenge · Build: a protected route that keeps sessions alive · Fill the blanksRead the session in your own route, pass any refreshed cookie on, and refuse anonymous callers.1: returnHeaders, 2: getSetCookie, 3: 401, 4: userWithout passing Set-Cookie on, people are signed out at the cookie's original expiry however active they are. 401 means not authenticated. The notes come from the id Better Auth verified, never from the request.
20. Mastery challenge · Design: an invite-only notes app for one company · QuizA company wants its staff, and only its staff, to use a notes app. Staff use Google accounts on company.example. Which choices fit?A. Google sign-in only, with disableSignUp on email and password; B. A before hook or databaseHooks.user.create.before refusing users whose verified email is not @company.example; D. The admin plugin, so IT can ban leavers and end their sessions at onceOne way in reduces what to secure. A database hook on user creation catches every path that makes users; checking the verified email domain is what proves staff. Admin bans revoke sessions, which matters when someone leaves. A wildcard trusted origin would accept requests and redirects from anywhere.
20. Mastery challenge · Respond: a takeover report · QuizBen says someone changed his email address and he can no longer sign in. The audit log shows a sign-in from a new country, then an email change, then GitHub linked. What do you do first?B. Revoke all of the account's sessions, unlink the attacker's GitHub, and restore Ben's email after verifying it is himContain this one account: every session and every way in the attacker added must go, or they walk back in. Restore control to Ben only after proving it is him through a channel the attacker does not hold. Then ask how it happened: a reused password suggests stuffing, so check for others. Rotating the secret signs everyone out for a single-account problem.