4. Signing in
One table, one list, and everyone who opens the page sees all of it. Giving each person their own memos needs an answer to a question the application currently cannot ask: who is this?
Login is the part of a framework that usually arrives as three routes, a protocol library, and a week of reading. Popcorn Web serves the routes itself. What is left for the application is a column, a filter, and a decision about which paths require a session — which is what this chapter is. Thirty minutes, including the wizard.
1. Install the capability
Section titled “1. Install the capability”pw add authThe wizard asks two things. Answer auth, then choose the local emulator
when it asks about the OIDC provider. The review screen lists what will be
written before anything is:
Review Capability auth OIDC provider Local emulator
create devidp.toml create handlers/accounts.go create migrations/00003_init_popcornweb_session.sql create migrations/00004_init_popcornweb_auth.sql append config.dev.toml append popcornweb.toml edit cmd/memoapp/main.go then pw migrate updevidp.toml is the roster of development users, Administrator and Member.
pw dev serves them from a local OpenID Provider and checks no password, which
is exactly why it never runs outside development.
A login needs two kinds of storage
Section titled “A login needs two kinds of storage”Two migrations is not an accident. A login puts two different things on the server:
| What | Holds | Package |
|---|---|---|
| Sessions | who is signed in | sessionstore/sqlite |
| Ceremony records | the single-use state of one login in progress | authstate/sqlite |
auth decides who this is. It sends the browser out to a provider, verifies the identity that comes back, and settles on an account ID for this application. It runs at sign-in and not again.
session carries who it decided. On every request after that, it holds who this is and what is true of them right now.
A session is the record behind the opaque token the browser carries, which is what makes it revocable from the server. A ceremony record correlates the one round trip out to the provider and back, and is consumed when it returns.
session.backend = "rdb" in config.dev.toml chooses only where they go.
What puts that somewhere into the binary is the import. That is what “storage
is opt-in” means: an application links the backend it configured and no other, and
a project keeping sessions in a cookie links no store at all. The cookie option
is one of the backends compared in session storage,
and pw init --session offers it from the start.
When the configuration and the imports disagree, startup says so:
popcornweb: auth.session: session.backend = "rdb" needs its plugin;add to the application: import _ "github.com/shibukawa/popcornweb/sessionstore/sqlite"That is the edit cmd/memoapp/main.go line. Accept the screen and the file
becomes this:
package main
import ( "context" "log"
"memoapp/handlers"
// new: this is what actually provides session.backend = "rdb". _ "github.com/shibukawa/popcornweb/sessionstore/sqlite" // new: where the single-use login records go. _ "github.com/shibukawa/popcornweb/authstate/sqlite"
"github.com/shibukawa/popcornweb/pw")
func main() { // new: installed before Run, because the framework calls it during the // OIDC callback. handlers.RegisterAccounts() if err := pw.Run(context.Background(), handlers.Handlers()); err != nil { log.Fatal(err) }}A project that chose the login at pw init has these three lines in its
scaffold already, which is exactly why pw add writes them too: a capability
declined at bootstrap and installed later has to arrive at the same file as one
that was never declined.
main.go belongs to the application, so editing it is not something a command
should do quietly. What makes it acceptable is that the review screen names the
file before anything is written, and that the edit is spliced in at a position
the parser found — the rest of the file is copied through unchanged, comments,
grouping, and all.
RegisterAccounts lives in the handlers/accounts.go the wizard just wrote.
It is one line — auth.SetAccountResolver(resolveAccount) — and it installs
whichever account seams the selected mode needs. The framework verifies an
identity with the provider and then asks resolveAccount which local account it
belongs to; the starter version derives one from the identity itself, so the
project can log in before it owns an account table.
2. Decide what needs a session
Section titled “2. Decide what needs a session”pw add auth appended an [auth] section to config.dev.toml with nothing
protected:
protection.include = []Every path is public until it appears in that list. The memo pages should not be:
protection.include = ["/", "/memos"]protection.unauthenticated = "redirect"Patterns match whole path segments, and they match exactly unless they end in
**. "/" is the root and nothing below it; "/memos/**" would be the subtree.
Listing the two paths this application actually has keeps /healthz and the
other operational endpoints public, which is what a load balancer needs them to
be.
In popcornweb.toml, what pw add auth wrote is already what you want:
[dev.idp]enabled = trueconfig = "devidp.toml"port = 18080The port is deliberate. Without it the provider takes a free port and the
issuer URL changes on every pw dev. The issuer is half of what identifies an
account: the resolveAccount you will read in section 4 builds an account ID out
of issuer + "|" + subject. A moving port would hand the same person a new
account after every restart — and, three steps from now, an empty memo list. If
something already listens on 18080, change it.
Then apply the migrations:
pw migrate upSign in before writing any code
Section titled “Sign in before writing any code”Nothing in handlers/ has changed, and there is already something to try. Start
the loop:
pw devAlongside the usual output, it reports the provider it started:
devidp: development identity provider on http://127.0.0.1:18080; no password is checkedpw dev: identity provider http://127.0.0.1:18080pw dev: login screen http://127.0.0.1:18080/loginpw dev: client pw-dev-xCD4SA98_as (secret injected as AUTH_OIDC_CLIENT_SECRET)pw dev: users admin, memberThe issuer and the client credentials reach the application process as environment variables, so nothing about the provider is written into a committed configuration file.
Open http://localhost:8080/ — the host pw dev printed, and the one the
scaffolded auth.oidc.redirect_url names. Both matter and they are the same
fact: a login begun at one origin returns to another origin’s cookies, so the
callback would arrive holding nothing to check and be refused.
You do not reach the memo page. The request is redirected to the provider’s login screen, where two users are waiting. Pick Member.

The browser comes back to the memo page from chapter 3, unchanged. It still says Memos, and it still lists everything anyone wrote. That is the honest state: there is a session now, and the application has not yet asked whose it is.
What happened in between, this project did not implement — the redirect to the
provider, the callback, the verification, the account lookup, the session record,
and the cookie. Three routes came with the capability, /auth/login,
/auth/callback, and /auth/logout, and no handler here mentions any of them.
Everything you edited to get this far was configuration.
The rest of the chapter is the part a framework cannot do for you: deciding that a memo belongs to somebody.
3. Give a memo an owner
Section titled “3. Give a memo an owner”The table predates the notion of an owner, so add the column in its own
migration rather than editing the one that has already been applied. Create
migrations/00005_add_memo_author.sql:
-- +goose UpALTER TABLE memos ADD COLUMN author TEXT NOT NULL DEFAULT '';
-- +goose DownALTER TABLE memos DROP COLUMN author;The default matters: rows written in chapter 3 have no owner, and NOT NULL
without a default cannot be added to a table that already has rows. Those older
memos end up owned by the empty string, which no account is — they stay in the
table and stop appearing on any page.
Both statements in queries/memos.pw.sql now take the owner:
export statement ListMemos(author: string): sql.many<Memo> {SELECT id, body FROM memos WHERE author = {author} ORDER BY id DESC}
export statement CreateMemo(author: string, body: string): sql.exec {INSERT INTO memos (author, body) VALUES ({author}, {body})}Changing a statement’s parameter list changes the generated function’s signature, so every caller stops compiling until it passes the new argument — the same boundary chapter 1 demonstrated with a template.
Save both files and watch it happen. pw dev regenerates, then stops:
handlers/home_handler.go:26:35: not enough arguments in call to queries.ListMemos have (context.Context) want (context.Context, string)handlers/home_handler.go:45:38: not enough arguments in call to queries.CreateMemo have (context.Context, string) want (context.Context, string, string)Two errors, and between them they are the work remaining in this chapter. A column added to a table is a schema change that no compiler can see; a column added to a statement is a signature change that every caller has to answer. This is the moment to notice that the migration and the query edit were two different kinds of act, even though they went into the same table.
Section 4 answers both errors, and the answer to each is the same three lines: ask who this is.
4. Read the user
Section titled “4. Read the user”The framework resolves the session before any handler runs. auth.User reports
what it found:
import ( "net/http"
"memoapp/queries"
"github.com/shibukawa/popcornweb/plugin/auth" // new "github.com/shibukawa/popcornweb/pw")
// home lists the signed-in account's memos.//// changed: the home of chapter 3, plus the user lookup and the author filter.func home(w http.ResponseWriter, r *http.Request) { user, ok := auth.User(r.Context()) if !ok { pw.WriteProblem(w, r, pw.Unauthorized()) return } var list []Memo // changed: the author is a new argument, and it is what makes this one // person's list rather than everyone's. for row, err := range queries.ListMemos(r.Context(), user.AccountID) { if err != nil { pw.WriteProblem(w, r, err) return } list = append(list, Memo{Id: row.Id, Body: row.Body}) } pw.WriteHTML(w, r, Home(HomeParams{DisplayName: user.DisplayName, Memos: list}))}The guard configured in step 2 already redirected every anonymous request away
from this path, so the !ok branch should be unreachable. Write it anyway. It
costs three lines, and it means a later edit to protection.include cannot
silently turn user.AccountID into an empty string that matches every
unowned row.
createMemo changes the same way — read the user first, then pass
user.AccountID to queries.CreateMemo:
// createMemo stores one memo as the signed-in account's own.func createMemo(w http.ResponseWriter, r *http.Request) { // changed: these two lines are what decides whose memo this is. user, ok := auth.User(r.Context()) if !ok { pw.WriteProblem(w, r, pw.Unauthorized()) return } input, err := pw.Parse[createMemoInput](r) if err != nil { pw.WriteProblem(w, r, err) return } // changed: the author is a new argument. if _, err := queries.CreateMemo(r.Context(), user.AccountID, input.Body); err != nil { pw.WriteProblem(w, r, err) return } http.Redirect(w, r, "/", http.StatusSeeOther)}The page takes the name and offers a way out:
package handlers
type Memo { id: int body: string}
export component Home(displayName: string, memos: Memo[]): html { <h1 class="text-3xl font-bold">{displayName}'s memos</h1> <form method="post" action="/auth/logout"><button type="submit">Sign out</button></form> <form method="post" action="/memos" class="mt-6 space-y-2"> <textarea name="body" rows="3" required maxlength="200" class="w-full rounded-lg border border-slate-300 p-3"></textarea> <button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white">Add</button> </form> <ul class="mt-8 space-y-2"> {for memo in memos} <li class="rounded-lg border border-slate-200 p-3">{memo.body}</li> {/for} </ul>}Sign-out is a form rather than a link because the endpoint accepts POST only.
A link that ends a session can be triggered by a browser prefetch or an image
tag on someone else’s page; a GET /auth/logout therefore answers 405.
5. One account, then the other
Section titled “5. One account, then the other”Save. The build succeeds, the application restarts, and the page you were
holding — still signed in as Member from section 2 — now says Member’s
memos. The memos from chapter 3 are gone from it. They are still in the table;
they are owned by the empty string, and Member is not the empty string.
Write a memo. Then Sign out, sign in as Administrator, and the list is empty — an account this application has never seen before, looking at the same table. Write one of its own. Sign back in as Member, and Member’s memo is there and Administrator’s is not.
The data pane is the flat version of the same fact. One memos table, an
author column with two distinct values in it, and every row still visible to
anyone who can open the console. Separation between accounts is a WHERE clause,
not a property of the storage — which is precisely why the !ok branch you were
told to write anyway is worth its three lines.
What is deliberately missing
Section titled “What is deliberately missing”The development provider is a stand-in. Deploying this application means naming
a real OpenID Provider and supplying AUTH_OIDC_ISSUER, AUTH_OIDC_CLIENT_ID,
AUTH_OIDC_CLIENT_SECRET, and SESSION_SECRET through the environment; the
application refuses to start without them rather than falling back to something
insecure.
The starter resolver derives an account from the verified identity instead of
storing one. A real application owns an accounts table, links it to the issuer
and the identity claim, and decides what to do with an identity it has never
seen. Authentication covers that, and
examples/oidclogin in the repository is a working version of it.
And auth.User answers who, never what they may do. Authorization stays in
the application — including the question this chapter answered with a WHERE
clause.
What you built
Section titled “What you built”Across four chapters: a project scaffolded and running, a form whose rules are declared once and enforced before the handler body, a schema under version control with typed SQL over it, and a login whose protocol work happened somewhere else entirely.
All of it is an ordinary server-rendered application, and nothing so far needed a template language of its own to build. Chapter 5 is where that changes: a route the filesystem describes, and the three ways one of its pages keeps changing after it has been rendered.
