Skip to content

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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].

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.

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 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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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 = true
csrf.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.

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.

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 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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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 revoked

Neither 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.

pw doctor reads the merged configuration for the environment you name and reports what is off:

Terminal window
pw doctor --env prod

It 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.