Skip to the content

Module 1: Who you are, and what you may do

Module 2: HTTP forgets you, cookies remind it

Module 3: Sessions and tokens

Module 4: Storing passwords safely

Module 5: How accounts get attacked

Module 6: Installing Better Auth

Module 7: Serving it from a Hono server

Module 8: The client in the browser

Module 9: Email and password, properly

Module 10: Emailed links: verification, resets and magic links

Module 11: Sessions in depth

Module 12: Social sign-in with OAuth, and linking accounts

Module 13: Protecting routes, and roles

Module 14: Plugins: two-step sign-in, passkeys and teams

Module 15: Hooks and fields of your own

Module 16: Rate limits, bots and breached passwords

Module 17: Testing auth flows

Module 18: Security hardening

Module 19: Running it in production: domains, audits, incidents

Module 20: Mastery challenge

Answer key

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.
  11. 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.
  12. 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).
  13. 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).
  14. 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.
  15. 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.
  16. 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.
  17. 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.
  18. 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.
  19. 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.
  20. 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.
  21. 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.
  22. 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.
  23. 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.
  24. 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.
  25. 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.
  26. 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.
  27. 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.
  28. 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.
  29. 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.
  30. 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.
  31. 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.
  32. 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.
  33. 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.
  34. 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 401 Response, 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.
  35. 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.
  36. 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.
  37. 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.
  38. 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).
  39. 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).
  40. 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.
  41. 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.
  42. 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.
  43. 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.
  44. 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.
  45. 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).
  46. 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.
  47. 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.
  48. 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.
  49. 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 providerId github 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.
  50. 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.
  51. 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.
  52. 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.
  53. 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.
  54. 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.
  55. 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.
  56. 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.
  57. 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.
  58. 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.
  59. 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.
  60. 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.
  61. 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.
  62. 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.
  63. 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.
  64. 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.
  65. 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.
  66. 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.
  67. 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.
  68. 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.
  69. 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.
  70. 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.
  71. 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.
  72. 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.
  73. 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.
  74. 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.
  75. 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.
  76. 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.
  77. 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.
  78. 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.
  79. 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.
  80. 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).
  81. 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).
  82. 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.
  83. 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.
  84. 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.
  85. 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.
  86. 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.