Security
Much of the framework’s security is already decided before you write a handler. Some defenses are always on, others need configuration, and a few remain the application’s responsibility. That distinction matters: an absent defense is far easier to address when you know it is absent.
The request path below shows where each check runs. It also makes the boundary clear: Popcorn Web can reject an unsafe request, but it cannot decide who should be allowed to perform an application-specific action.
Security first
Section titled “Security first”Popcorn Web is developed on the premise that a web application is attacked, and that most of what an attacker tries is the same handful of moves against every site. So every defense that can be decided without knowing the application is decided by the framework, as early as possible: at generation time, where tinybind-go compiles the template, the query, and the request binding into type-specific Go with no reflection; or in the chain above the handler, where a request is measured, parsed, and sometimes refused before any application code runs.
The other half of the premise is that a defense you cannot see is one you cannot
rely on. The safe setting is the default, a setting that turns a defense off
refuses to start outside development or warns and is reported by pw doctor,
and a refusal tells the caller nothing about which check failed. The rest of
this page is the detail; this section is the map.
What Popcorn Web already handles
Section titled “What Popcorn Web already handles”Oversized bodies
Section titled “Oversized bodies”server.max_request_body caps every request at 10 MiB, outside your own
middleware, so a middleware that reads the body is bounded too; the fasthttp
build refuses the request with 413 before a handler runs. Inside that cap each
decoder has its own bound, because decode memory is a different cost from
transfer size: 1 MiB for a JSON document, a multipart body, and a CBOR body
(server.cbor_max_body), 16 KiB for a bearer token, 128 bytes for a request
ID, and none of them can be set to unlimited. See
[server] and tinybind-go’s size and shape
limits.
Slow and half-open connections
Section titled “Slow and half-open connections”Five seconds to send the headers, thirty to send the whole request, two minutes
idle on a keep-alive connection: the timeouts are what take a goroutine back
from a client feeding bytes one at a time. The write timeout is zero because a
live response is meant to stay open, and html.live_idle_timeout closes one
that no source has delivered on for five minutes. middleware.request_timeout
puts a deadline on the request context once you set one.
Compression bombs
Section titled “Compression bombs”A request body is never decompressed, neither by this framework nor by
tinybind-go underneath it: a Content-Encoding on a request is ignored, so a
few kilobytes that would inflate to gigabytes arrive as kilobytes, under the cap
above. Compression is outbound only, precompressed assets are produced by pw build, and the bytes read from an identity provider are bounded after the
transport’s own decompression. WebSocket permessage-deflate, the one inflating
codec in the stack, is off by default, and ReadLimit counts the message after
inflation.
Malformed input and unexpected parameters
Section titled “Malformed input and unexpected parameters”pw.Parse[T] binds a request through code generated for that one struct, so
the struct is the whole surface: a key no field names is skipped unless a
payload:"*" rest map asks for it, and a value that does not parse or fails a
check rule fails the whole bind with one 400 problem document naming each
field. There is no prototype on the server for __proto__ to pollute, and a
document deeper than 90 levels is refused so its depth cannot become the Go
stack’s. A session cookie with the wrong syntax never reaches the store, and a
request ID outside 128 printable ASCII characters is replaced rather than
echoed. See Request Binding.
Session fixation
Section titled “Session fixation”The server never adopts a token it did not mint: an unknown cookie is cleared
and the request continues anonymous, and a minted token is 256 random bits
stored under its SHA-256 hash. A login rotates the session — the old record is
revoked first, a new token issued, the CSRF secret minted afresh — so whatever a
browser held before the login is dead after it, and a step-up rotates for the
same reason. The cookie is HttpOnly, Secure, and SameSite=Lax, and
secure = false refuses to start outside development. See The move that
happens at login.
Password capture and code interception
Section titled “Password capture and code interception”The framework never handles a password. Browser login is OpenID Connect, or
plain OAuth 2.0 for a provider without ID Tokens, and the only grant is
Authorization Code with mandatory S256 PKCE; the password, implicit, and
client credentials grants are not implemented. The state, the PKCE verifier,
and the nonce live in a server-side transaction consumed exactly once, the
browser holding only an opaque key in a ten-minute cookie, and an ID Token is
accepted only under RS256 with its nonce and azp checked. Browserless
devices use the Device Authorization Grant at the interval the server sets.
Passkeys are WebAuthn bound to the configured origins, with user verification
on demand and a reversed signature counter reported. pw dev runs a local
OpenID Provider that checks no password and refuses prod, so an application
is tested signed in through the real login, session, guard, and CSRF check
rather than behind a bypass flag. See Authentication
design and
the development identity provider.
Forgery and injection in the page
Section titled “Forgery and injection in the page”Cross-site request forgery, script injection, javascript: URLs, framing, and
the compression side channel on the CSRF token each have a section of this
page: see What is on by
default and How CSRF
works here. The escaping
and URL rules are tinybind-go’s htmlbind, which decides what a value may become
by the position it lands in — text and attributes are escaped, a URL attribute
demands a url at generation time and a permitted scheme at render time, and a
<script> body or event handler takes nothing a string can reach; its
security
reference
has the full table. pw.Redirect refuses a target a browser could not follow
without running script, and the login’s next parameter accepts only a rooted
local path.
SQL injection
Section titled “SQL injection”Every {name} in a .pw.sql body is a prepared-statement placeholder:
tinybind-go’s sqlbind never concatenates a template expression into SQL text,
refuses a handwritten $1 or ?, and lets no value stand where an identifier,
an operator, or a sort direction belongs. It also removes the reason people
concatenate: a search with optional conditions is written as plain SQL with
{if} blocks punched out of it, and the compiler withholds dangling ANDs and
commas, drops an empty WHERE, renumbers the placeholders, and expands a slice
into an IN list, so the clause injection enters through is never assembled by
hand. An UPDATE or DELETE whose WHERE could be empty on some branch is
refused at generation. See Conditional
SQL, Two safety
rules, and sqlbind’s Values are
always
parameters.
Path traversal
Section titled “Path traversal”Static assets are served from the tree pw build embedded, so outside
development there is no disk to traverse, and the request path still has to be
canonical: dot-prefixed segments, .., backslashes, NUL, and symbolic links are
refused, after one round of percent-decoding. See Path
handling.
Flooding and brute force
Section titled “Flooding and brute force”Rate limiting is off until configured, because the proxy in front of a
deployment already limits by address; what it adds is the budget the edge cannot
compute, per signed-in user — 600 requests a minute to a user and 300 to an
address by default, 429 with Retry-After past that, and a process-wide
ceiling. The address counted comes from X-Forwarded-For only when the peer is
in server.trusted_proxies, so a header cannot buy a fresh budget, and a forged
kid triggers at most one key refresh per cooldown. See Rate
Limiting.
Timing attacks
Section titled “Timing attacks”Every comparison against a secret — the CSRF token, a signed cookie’s MAC, a
passkey challenge, an HMAC bearer token, a signed storage URL, an OIDC nonce —
is constant time, so response time says nothing about how close a guess came.
Token attacks
Section titled “Token attacks”A bearer token is parsed under hard bounds — three segments of 8 KiB, 16 levels
of nesting, 256 members, no duplicate members, no crit, integer dates — before
anything is verified. alg: none is refused, the allowlist cannot name a
symmetric algorithm, and the key is resolved by kid from a JWKS on the
issuer’s own origin, fetched over HTTPS without redirects, for the same
algorithm the header names; an ambiguous match or an RSA key under 2048 bits is
refused. Then the whole claim set is required: iss, aud with any or all
of the configured values, exp, nbf, and iat within a leeway capped at
five minutes, exp - iat within max_token_lifetime, sub, a typ of
at+jwt so an ID Token cannot be replayed as an access token, jti, and every
required_scopes entry. Only then does the request reach admission and
revocation, which fail closed, and every rejection is the same 401 with
WWW-Authenticate: Bearer. An unknown kid triggers at most one refresh per
cooldown, and cached keys outlive an outage by an hour, as Known
limits
explains. See JWT-only API
servers and
[auth.jwt].
Crashes and error detail
Section titled “Crashes and error detail”A panic in a handler becomes a 500 through the same negotiated error path as any other failure, with the panic in the log and nothing of it in the response, and outside development an error page carries a status and a title and nothing else. See Different with no setting at all.
Non-functional security concerns
Section titled “Non-functional security concerns”These are not implemented in the framework, because another layer or the application is the right owner. The list above does not replace a review of what your own handler trusts; What you still own names the rest.
TLS termination
Section titled “TLS termination”TLS terminates at the load balancer or the CDN. The framework binds a plain
port, learns that a request was HTTPS from its own TLS connection or from the
X-Forwarded-Proto a proxy in server.trusted_proxies sent, and sends HSTS
only on a request verified that way. A process that must serve TLS itself can,
because pw.Middlewares returns the composed chain as an ordinary
http.Handler. See Reverse Proxy.
Volumetric denial of service
Section titled “Volumetric denial of service”Traffic that saturates a link or a port is absorbed by the WAF or the edge, which sees it before the process does. The timeouts, caps, and rate limits above are application-layer flood control, and the budget per signed-in user is the one part only the application can compute.
Authorization
Section titled “Authorization”A validated session or bearer token says who is asking, not what they may do.
Path protection covers whole routes; anything finer belongs to the handler, or
to {check …} in the page.
What a request value may control
Section titled “What a request value may control”Nothing in a Parse struct is unbindable, so a value the caller must not
control does not belong in one: bind what the request may carry and derive the
rest in the handler. A component published as redrawable makes its parameters
attacker-controlled, and checking ownership is the component’s own job.
Where a redirect may go
Section titled “Where a redirect may go”The URL rules refuse a scheme that executes, not a destination: //other.example/
is navigable, and ping and srcset accept any destination that parses. The
login’s next is the one place the framework insists on a local path;
everywhere else, a user-supplied destination is yours to validate.
Uploaded files and archives
Section titled “Uploaded files and archives”An uploaded file is bytes to the binder: the framework never opens an archive, never renders an upload, and bounds only its size. Unpacking, scanning, and serving what a user uploaded are decisions the application makes with its own limits.
Template positions without a rule
Section titled “Template positions without a rule”htmlbind does not yet filter a meta refresh URL, applies HTML escaping rather
than CSS escaping to a style attribute, and has no rule for srcdoc. Do not
build any of them from untrusted input; the security
reference
tracks the list.
Secrets already leaked
Section titled “Secrets already leaked”Values marked secret are redacted from logs and the startup summary, and pw doctor reports a literal secret in a committed file; neither can tell you the
value was already taken. Rotation is yours.
What a request passes through
Section titled “What a request passes through”A request reaches your handler through a chain, and the order is not arbitrary. Each stage depends on the one above it having already run.
| Stage | What it decides |
|---|---|
| Operational endpoints | Health, readiness, and framework assets answer above everything |
| Public assets | A static file is served without touching a route |
| Body limit | An oversized body is refused before it is read |
| Security headers | The response policy the browser will apply |
| Recovery | A panic becomes a response instead of a dropped connection |
| Session | The cookie becomes a validated record, or the request is anonymous |
| Authentication | The login endpoints answer their own paths |
| CSRF | An unsafe request proves it came from your page |
| Guard | An unauthenticated request to a protected path is redirected |
| Your handler | — |
The three bold stages are where identity turns into authority. Everything above
them is about the shape of the request; everything from Session down is about
who is making it.
CSRF sits below authentication for a reason that is easy to miss. The login endpoints answer above it, so a login POST and an OIDC callback never need a token — they never reach the check at all. That is not an exclusion you configure; it falls out of where the stage is.
What is on by default
Section titled “What is on by default”Five things protect a project that configured nothing.
Response headers. X-Content-Type-Options, X-Frame-Options, a referrer
policy, and a narrow Content-Security-Policy go out on every response. The
policy restricts scripts, plugins, the document base, and framing, and leaves
images, fonts, styles, and connections alone so an ordinary page keeps working.
See Security Headers for what it says and
when to write your own.
Escaping. A template inserts values through typed holes, and each one is escaped for the position it lands in. Producing raw markup takes an explicit intrinsic, so it is visible in review rather than implicit in a helper.
URL schemes. Escaping is the wrong tool for a URL, because the danger is
which scheme the browser executes rather than which characters it reads —
javascript:alert(1) survives every escaper unchanged. So a URL-bearing
attribute is checked against a scheme allowlist and renders #tb-blocked-url
when it fails. See URL
attributes.
Opaque session tokens. The browser holds 256 random bits. The store is keyed by a SHA-256 hash of that value, so a leaked database dump cannot be replayed as a cookie.
Static assets that are what they claim. pw build refuses a file in
public/ whose bytes contradict its extension — a .png holding a scripted
SVG never reaches the binary. Every image/svg+xml response also goes out
sandboxed, because SVG is the one served image that executes and it would
otherwise execute in your origin. See Static
assets.
What waits for a line of configuration
Section titled “What waits for a line of configuration”CSRF is off until you name the paths it covers. That is deliberate: a check
installed over nothing reads as protection that is not there, and csrf.include = [] would be a worse lie than csrf.enabled = false.
A browser login names one on the way in, so pw init writes the shape and turns
it on — the scaffolded sign-out control is a form, and a generated form is
refused rather than rendered without a token:
[security]csrf.enabled = truecsrf.include = ["/**"]csrf.exclude = []Without a login there is no session to bind a token to, and pw init writes no
[security] section at all.
Path protection is the same shape. protection.include = [] means every
path is public, and a project opts routes in rather than out.
HSTS is off until a deployment is certain about its certificate, because turning it on is hard to undo in a browser that already cached it.
How CSRF works here
Section titled “How CSRF works here”A CSRF token proves a request came from a page your server rendered. The mechanism is a secret the server knows and the attacker’s page cannot read.
The secret
Section titled “The secret”One secret per session, minted when the session is created and stored in the session record. Rotation is automatic: logging in replaces the session, and the secret goes with it, so a token minted before a login is refused after one.
The secret never reaches a handler. It is deliberately absent from the session view your code reads, and it never enters template scope.
The three channels
Section titled “The three channels”The token reaches the browser three ways, and all three verify against the one secret.
| Channel | Written by | Read by |
|---|---|---|
| Hidden form field | The template compiler, into every unsafe form | A plain form submission |
pw_csrf cookie |
The session manager, beside the session cookie | The browser runtime |
X-CSRF-Token header |
The runtime, from the cookie | The server |
The hidden field is the part you never write. Any <form method="post"> — or
put, patch, delete — gets one as its first child at generation time. A GET form
does not, because its fields become the query string and a token in a URL
reaches history, logs, and referrers.
Two forms of the same page are refused at generation rather than at runtime: a form posting cross-origin, which would hand your session’s secret to a third party, and a form whose method is computed, which cannot be classified as safe or unsafe.
Why the cookie exists
Section titled “Why the cookie exists”The header could have come from the page. It does not, and the reason is rotation.
A token embedded in the rendered HTML is fixed at render. If the session rotates while the page is open — a login in another tab, a privilege change — the page holds a token the server no longer accepts, and the next action fails with nothing on screen explaining why. Reading the cookie at the moment a request is issued closes that: the rotation wrote a new cookie, and the runtime picks it up on its next request.
This is the shape Django, Laravel, and Spring’s SPA configuration all use. It
costs one cookie that JavaScript can read, which is the one documented exception
to the rule that framework cookies are HttpOnly. A runtime that cannot read
the token cannot send it.
Why the bytes differ every time
Section titled “Why the bytes differ every time”Each emission carries a fresh random pad, so two renders of one page produce different token bytes even though both unmask to the same secret.
The reason is compression. Responses are compressed, and a secret that appears unchanged in a compressed body alongside attacker-influenced text — an echoed search query, a redisplayed form value — can be extracted a byte at a time by watching response sizes. A pad that changes per response leaves nothing to accumulate.
Verification rebuilds the expected value from the pad the request carried, then compares in constant time. Rails and Django mask for the same reason.
Anonymous visitors
Section titled “Anonymous visitors”A token needs a secret, and a public page with its own unsafe form — a contact form, a search POST — has no login to take one from.
It needs no separate mechanism. The secret is a
registered session slot
declared session.Private, and a private slot rides a sealed cookie while the
session is anonymous. So an anonymous visitor gets a secret and the store gets
no row: a crawler that loads the page costs a cookie, not a record.
Once the visitor signs in, the login rotation moves the same slot onto the configured backend and mints a fresh secret with it. There is no anonymous secret left in play, because there was never a second one — one slot serves both populations, and nothing here is configured on its own.
For a while the two were separate: a record field for a signed-in visitor and a signed cookie for an anonymous one, under its own key and its own opt-in. That split existed to keep anonymous traffic from deciding how many rows the store holds. Placing a private slot in a cookie until the login answered the same question, so the second mechanism went away.
What a refusal looks like
Section titled “What a refusal looks like”A refused request gets 403 through the same error path as any other — your HTML error page in a browser, a problem document for an API client — and your handler is never called.
The response never says which check failed. The reason reaches the log, because naming it in the response tells a caller which half to work on.
What you still own
Section titled “What you still own”The framework does not decide these.
Authorization. A validated session says who is asking, not what they may
do. Path protection covers whole routes; anything finer is yours to write — in
the handler, or in the page itself with
{check …}, which calls
a Go function for its error alone and lets a refusal choose the response before
a byte is written.
Which paths are unsafe. csrf.include is yours to narrow. So is
csrf.exclude, and a webhook belongs there: it has no session and carries its
own authentication.
Registered component inputs. Publishing a component as redrawable makes its
parameters attacker-controlled. A component that formats values handed to it is
safe; one that loads a record by identifier must check ownership itself, exactly
as a handler would — which is what {check …} is for. Registration is the
review point.
Secrets in configuration. Values marked secret are redacted from logs and
the startup summary, and pw doctor reports a literal secret in a committed
file. It cannot tell you the value was already leaked.
What development is allowed to do
Section titled “What development is allowed to do”A number of settings mean something different depending on APP_ENV. None of
them is read from a request, and none is inferred from a hostname — the
environment is a token the deployment sets, and the framework treats an
unrecognized one as a deployment rather than as development.
The three tiers below are worth telling apart, because they fail at different times and one of them never fails at all.
Refused outside development
Section titled “Refused outside development”These stop the process from starting. Each one turns a defense off rather than weakening it, which is why none of them is left to a warning.
session.cookie.secure = false lets the session cookie travel over plain http.
pw init writes it into the development configuration on purpose, and outside
dev startup fails naming the setting. The exception is same_site = none
without secure, which fails everywhere, because no browser accepts that
combination as a cross-site cookie in the first place.
auth.jwt.dev.trust_unverified_tokens admits a hand-written bearer token with
no signature, so a developer with curl needs no authorization server. It has
four locks and needs all of them: the pwdev build mode, an APP_ENV that is
not stg or prod, the setting itself, and a request from a loopback address
with no opt-out. A binary built without the tag fails at startup when it merely
sees the setting, rather than ignoring it. What it does not relax is as
important: admission, revocation, and the parser bounds all keep running, so a
developer exercises the real rules rather than a bypass of them.
The development identity provider
authenticates nobody — signing in means picking a user from a list — so it
refuses to start under prod and binds to loopback with no opt-out. pw build
also fails when the application being built imports it, so it cannot reach a
release artifact by accident. Authentication
covers how pw dev wires it up.
Allowed outside development, with a warning
Section titled “Allowed outside development, with a warning”These start, warn at startup, and are reported by pw doctor. They cost
visibility, durability, or defense in depth rather than switching authentication
off, so the judgement stays with the deployment.
| Setting | What it costs outside dev |
|---|---|
security.csrf.enabled = false |
Unsafe requests are not checked for origin or token |
security.headers.enabled = false, hsts.enabled = false |
The browser applies a weaker response policy |
observability.query.enabled |
SQL text reaches diagnostic records |
observability.query.bind_values |
Row values reach them too — the only path by which application data enters a framework SQL record |
observability.minimum_level = trace or debug |
Verbose logs in a deployed process |
A sqlite in-memory DSN |
Schema and every row are lost at restart |
server.public.read_local = true |
Assets are served from disk rather than the built tree |
session.backend = cookie with auth.enabled |
A logout cannot end a session already issued — see below |
csrf.enabled is the one to look at first. The framework default is off, so a
project that scaffolded without a login and never turned it on is in this row
without having chosen to be.
Different with no setting at all
Section titled “Different with no setting at all”Error pages carry the whole problem in dev — message, cause, the detail the
Problem was built with — and only a status and a title anywhere else. There is
no configuration for this, and the template is the same in both: what changes is
what it is handed. In development the reader is the person who caused the
failure and is about to fix it. Everywhere else the same page is public.
One relaxation is deliberately not environment-gated:
auth.oidc.allow_loopback_http and its JWT counterpart permit an http issuer,
and they are bounded by the host actually being loopback rather than by
APP_ENV. That is a stronger bound than the environment token, which the
deployment sets and can get wrong.
Known limits
Section titled “Known limits”Four defenses here are bounded rather than absolute. Each bound is a consequence of something real — a browser keeps what it was given, a runtime cannot cancel a socket read — and none of them is a setting you can turn up until the problem goes away. They are listed because a limit you know about is one you can design around.
The cookie session backend cannot end a session
Section titled “The cookie session backend cannot end a session”session.backend = cookie seals the session record into the browser instead of
storing it. That makes it the cheapest backend to run and the only one that
needs no infrastructure, and it costs revocation: a record already written to a
browser cannot be taken back, so logging out expires the client’s copy while a
copy taken beforehand keeps working until its sealed expiry. Account suspension
has the same shape.
Pairing it with a login is the right trade in development, where a login that
needs no database is the whole point and the exposure needs a browser someone
else is holding. So dev says nothing. Every other environment gets a warning
at startup, and pw doctor reports PW0506 against the configuration:
the login session is stored where it cannot be revokedNeither one stops the process. The judgement stays with the deployment, which
is the only party that knows whether it can live without ending a session on
demand — but if you are reading that warning in a staging or production log, the
answer is almost certainly no. Move to rdb, redis, dynamo, or firestore: logout and
suspension are the two acts that make an incident survivable, and both need a
record the server can delete.
The cookie backend stays a good choice for a cart, a locale, or a wizard’s progress with no login in front of it.
Suspending an account takes up to 30 seconds
Section titled “Suspending an account takes up to 30 seconds”A suspended account is refused at login. It is also refused on requests that already have a session, because the account behind a live session is re-read as requests come in — but no more often than every 30 seconds per account, since reading it on every request would put a database round trip in front of every authenticated page.
That interval is the honest answer to how quickly a suspension reaches a session
someone is already holding. Call auth.ForgetAccount(accountID) from whatever
performs the suspension and the next request re-reads immediately; that is
process-local, so a deployment running several instances still waits out the
interval on the others.
When the account store cannot be reached at all, the request is refused with 503 rather than being admitted or signed out. The credential was never judged, a retry may succeed, and admitting on an outage would make every suspension conditional on the account database being up.
A TinyGo build enforces its own request timeouts
Section titled “A TinyGo build enforces its own request timeouts”TinyGo’s net/http dials and then reads with no deadline — its own source says
so, in a TINYGO: TODO handle timeouts comment — so a context.WithTimeout
around an OIDC discovery, a token exchange, or a JWKS fetch would bound nothing,
and a slow identity provider would hold the request handler until the peer
closed the connection.
The OIDC and JWT clients wrap their transport on TinyGo builds so the deadline returns to the caller regardless. What the wrapper cannot do is cancel the round trip underneath it, because that runtime offers nowhere to cancel it: a hung provider costs a goroutine and a connection until the socket itself fails, rather than a stalled handler. Requests keep being served, and a provider that hangs indefinitely under sustained traffic is still worth an alert.
None of this reproduces on a host Go build, including under
force_tinygo_logic, which selects the TinyGo code paths while still linking
the host net/http. Timeout behavior is one of the few things that has to be
tested on the real toolchain.
Cached JWKS keys expire even during an outage
Section titled “Cached JWKS keys expire even during an outage”Verification keys are fetched from the issuer and cached. When a refresh fails, the cached keys stay in use — an unreachable issuer does not make its keys wrong. That holds for an hour past the cache TTL, and then verification starts failing instead.
The reason is that a key withdrawn from the published document is withdrawn
whether or not this process can read the document that says so. An unbounded
stale window would make a compromised key usable for exactly as long as the
outage lasted, which is the window an attacker would choose. An hour is the
default; MaxStaleAge moves it, up to 24 hours, and there is deliberately no
setting for forever.
Checking what is on
Section titled “Checking what is on”pw doctor reads the merged configuration for the environment you name and
reports what is off:
pw doctor --env prodIt flags a disabled CSRF check, a session cookie without secure, weakened
response headers, and query diagnostics left on outside development. Running it
against production configuration from a developer machine is the point — the
findings are about the file, not the running process.
