- bouncer: Turnstile.Verify posts secret/response/remoteip to siteverify with a 5 s bound and PHP's open and closed modes (503 unconfigured in production, 422 on a missing, rejected or overlong token, an error or a timeout); errors never carry the secret or the token - bouncer: Turnstile.Middleware reads the token from the query, a JSON body or a form body, restores the body and writes refusals through a published TurnstileWriter or neutral JSON - surf: factory turnstile:open|closed from turnstile.secret/turnstile.field (a published *bouncer.Turnstile wins), client IP with trusted proxies; any other param fails boot - docs: Turnstile section, middleware table, README rows
bouncer
Authentication for SummerCMS: HS256 JWT minting, verification and refresh, request guards, a revoked-token blacklist and bcrypt password helpers.
import "git.golem15.com/golem15/summercms/modules/bouncer"
Name: Checks IDs at the beach club door; it authenticates requests and turns away the ones without a valid token.
Overview
bouncer decides who is making a request. Guards (bouncer.Guard, bouncer.CredentialGuard) turn an *http.Request into a bouncer.Principal; a bouncer.Registry holds named guards that plugins register and turns each one into HTTP middleware that stores the principal on the request context. The JWT side issues and checks HS256 tokens for two audiences, frontend users (bouncer.AudienceUser) and admin users (bouncer.AudienceBackend), with a refresh flow and a jti blacklist compatible with tokens issued by the PHP jwt-auth library. It is the counterpart of WinterCMS's Auth and BackendAuth facades and the JWT auth layer used by API plugins.
Role in the framework
A dependency-free security primitive. surf uses it for request guards, cabana for admin sign-in, and conga and lighthouse for authenticated workers and realtime tokens. It is separate so authentication code can be reviewed and tested on its own, without the HTTP or admin layers around it.
Use cases:
- Issue and refresh JWTs for a single-page or mobile frontend.
- Protect API routes with a guard and read the authenticated user downstream.
- Revoke tokens on sign-out and hash and check passwords with bcrypt.
Features
- Token minting with
bouncer.Mint(frontend audience) andbouncer.MintAudience(any audience), each returning the signed token and its random jti. - Verification with HS256 pinned and
expandsubrequired:bouncer.Verifyandbouncer.VerifyClaimsaccept frontend tokens, including legacy tokens with no audience claim;bouncer.VerifyClaimsAudiencerequires an explicit audience, so a backend token cannot pass a frontend check and the other way round. - Refresh with
bouncer.Refresh,bouncer.RefreshAudienceandbouncer.RefreshAudienceFor: an expired token can be reissued while itsiatis inside the refresh window; the old jti is blacklisted after a grace period.bouncer.RefreshAudienceForalso reloads the user and refuses deleted users and tokens issued beforebouncer.Principal.TokensValidAfter, reportingbouncer.ErrSubjectRejected. - JWT guards:
bouncer.NewJWTGuard(frontend) andbouncer.NewBackendJWTGuard(admin audience, optional custom 401 writer) read the bearer token first and then any configured cookies, load the user through abouncer.UserProvider, check the blacklist and thebouncer.Principal.TokensValidAftercutoff, and write a JSON 401 body ({"error":true,"message":...}, withCache-Control: no-cache, private) on failure. - Named guard registry:
bouncer.Registry.Registeraccepts anybouncer.Guardorbouncer.CredentialGuard;bouncer.Registry.Middlewarederives middleware that stores the principal (and credential, if any) on the context. Guards that do not implementbouncer.UnauthorizedWriterlet unauthenticated requests through so later middleware can decide. - Standalone bearer middleware:
bouncer.Middleware. - Context helpers:
bouncer.WithUserandbouncer.Userfor the principal,bouncer.WithCredentialandbouncer.Credentialfor the credential behind it (for example an API token record). - Scope limits for personal API tokens:
bouncer.ScopeLimitonbouncer.Principal.Scope(nil means unrestricted, as for a JWT session). A token guard sets it after filtering the token's scopes by the request method;Fullmeans the token acts as the owner on every method (*) or on GET/HEAD (admin:read). - Blacklist stores behind
bouncer.BlacklistStore:bouncer.MemoryBlacklistfor tests andbouncer.PostgresBlacklistfor production, which works on a caller-supplied table withjti,expires_atandvalid_untilcolumns and rejects unsafe table names. - Cookie transport:
bouncer.LoadCookieConfigreads theauth.cookie.*block into abouncer.CookieConfigand refuses an invalid domain,same_site: nonewithout Secure andsecure: falsein the production environment;bouncer.CookieConfig.TokenCookiebuilds the httpOnly token cookie whose Max-Age is the refresh window, andbouncer.CookieConfig.ForgetTokenCookiethe matching cookie that deletes it.bouncer.CookieRefreshHandlerandbouncer.CookieLogoutHandlerare ready-made handlers for a cookie-based refresh and logout; the application supplies the refresh or revoke call and a writer for its own response envelope. - Cloudflare Turnstile:
bouncer.Turnstile.Verifychecks a token against siteverify with a 5 second timeout, in PHP's open mode (an empty secret passes) or closed mode (an empty secret isbouncer.TurnstileUnavailablein production);bouncer.Turnstile.Middlewarereads the token from the query, a JSON body or a form body, restores the body and writes refusals through abouncer.TurnstileWriter. surf registers it asturnstile:openandturnstile:closed. - Passwords:
bouncer.HashPassword,bouncer.CheckPasswordandbouncer.NeedsRehash(bcrypt, cost chosen by the caller).
Usage
package blog
import (
"context"
"fmt"
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
type users struct{}
// FindByID loads the user behind a token subject; nil means "not found".
func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
return &bouncer.Principal{ID: id, PreferredLocale: "en"}, nil
}
func Routes(secret string) (http.Handler, error) {
guards := bouncer.NewRegistry()
guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist(), "token")
if err := guards.Register("acme.blog", "jwt", guard); err != nil {
return nil, err
}
auth, err := guards.Middleware("jwt")
if err != nil {
return nil, err
}
mux := http.NewServeMux()
mux.Handle("GET /api/me", auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, _ := bouncer.User(r.Context())
fmt.Fprintf(w, "user %d", user.ID)
})))
return mux, nil
}
func Login(secret string) (string, error) {
token, _, err := bouncer.Mint(secret, "42", "https://example.com/api/login", time.Hour)
return token, err
}
An application that keeps the token in an httpOnly cookie, for a frontend on www.example.com calling the API on api.example.com, loads the cookie config at boot and mounts the refresh handler on a POST route:
package acme
import (
"context"
"encoding/json"
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// RefreshRoute rotates the cookie's token. cfg is the app config
// (app.Config); users, secret and bl are the guard's; refreshTTL is its
// refresh_ttl.
func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, secret string, bl bouncer.BlacklistStore, refreshTTL time.Duration) (http.Handler, error) {
cookie, err := bouncer.LoadCookieConfig(cfg) // a bad auth.cookie block fails boot
if err != nil {
return nil, err
}
return bouncer.CookieRefreshHandler(bouncer.CookieRefreshOptions{
Cookie: cookie,
RefreshTTL: refreshTTL,
Refresh: func(ctx context.Context, token string) (string, error) {
return bouncer.RefreshAudienceFor(ctx, users, secret, token, bouncer.AudienceUser, refreshTTL, bl, 0, "https://api.example.com/api/auth/refresh")
},
Write: func(w http.ResponseWriter, r *http.Request, res bouncer.CookieResult) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.Status)
if res.Err != nil {
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "error": "UNAUTHENTICATED"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
},
})
}
API reference
| Identifier | Description |
|---|---|
bouncer.Principal |
The authenticated identity: user ID, locale override, token cutoff, admin permission grants and optional Scope. |
bouncer.ScopeLimit |
A personal API token's method-filtered scope: Full or one permission-pattern slice per allowed scope. |
bouncer.Principal.Scope |
Set only for personal API token principals; nil for JWT sessions. |
bouncer.Guard |
Resolves the bouncer.Principal for a request. |
bouncer.CredentialGuard |
Resolves the principal and its underlying credential in one pass. |
bouncer.UnauthorizedWriter |
Optional guard interface for writing its own 401 response. |
bouncer.UserProvider |
Loads a user by numeric token subject. |
bouncer.Registry |
Named guard registry; bouncer.NewRegistry creates one. |
bouncer.Registry.Register |
Registers a guard under a name on behalf of a plugin; duplicate names fail. |
bouncer.Registry.Middleware |
Returns HTTP middleware for a registered guard; unknown names fail. |
bouncer.Registry.Owner |
The plugin ID that registered a named guard. |
bouncer.NewJWTGuard |
Frontend JWT guard reading the bearer header and optional cookies. |
bouncer.NewBackendJWTGuard |
Admin JWT guard that requires the backend audience. |
bouncer.Middleware |
Standalone middleware that validates a bearer token and loads the user. |
bouncer.Mint |
Signs a frontend-audience token; returns the token and its jti. |
bouncer.MintAudience |
Signs a token for a given audience. |
bouncer.Verify |
Verifies a frontend token and returns its subject. |
bouncer.VerifyClaims |
bouncer.Verify plus iat, exp and jti. |
bouncer.VerifyClaimsAudience |
bouncer.VerifyClaims with a required audience. |
bouncer.Refresh |
Reissues a frontend token inside the refresh window and blacklists the old jti. |
bouncer.RefreshAudience |
Refresh for a token that carries the given audience. |
bouncer.VerifyRefreshableClaimsAudience |
Verifies signature and audience without checking exp, while the refresh window is open; for revoking a refreshable token on logout. |
bouncer.RefreshAudienceFor |
bouncer.RefreshAudience plus the guard's user checks. |
bouncer.ErrSubjectRejected |
The token subject is not a loadable user, or the token predates the user's cutoff. |
bouncer.AudienceUser, bouncer.AudienceBackend |
The frontend and admin audience values. |
bouncer.WithUser, bouncer.User |
Store and read the principal on a context. |
bouncer.WithCredential, bouncer.Credential |
Store and read the resolved credential on a context. |
bouncer.BlacklistStore |
Revoked-jti store with a grace window and sweeping. |
bouncer.NewMemoryBlacklist |
In-process blacklist for tests. |
bouncer.NewPostgresBlacklist |
Blacklist over a *sql.DB and a table name. |
bouncer.CookieConfig |
Token cookie attributes: Name, Domain, Path, Secure, SameSite. |
bouncer.CookieConfigSource |
The config methods bouncer.LoadCookieConfig reads (Lookup, String, Environment); *compass.Config satisfies it. |
bouncer.LoadCookieConfig |
Reads and validates auth.cookie.*; every error names the key. |
bouncer.DefaultCookieName |
token, the cookie name when auth.cookie.name is unset. |
bouncer.CookieConfig.TokenCookie |
The httpOnly cookie carrying a token, Max-Age the refresh window passed in. |
bouncer.CookieConfig.ForgetTokenCookie |
The same cookie with an empty value and a past expiry, which deletes it. |
bouncer.CookieRefreshHandler |
Reads the token cookie, calls the application's refresh func, sets the rotated cookie and hands a bouncer.CookieResult to the writer. |
bouncer.CookieRefreshOptions |
Cookie config, refresh window, refresh func and writer of bouncer.CookieRefreshHandler. |
bouncer.CookieLogoutHandler |
Revokes the cookie's token through the application's func, clears the cookie and writes 200. |
bouncer.CookieLogoutOptions |
Cookie config, revoke func and writer of bouncer.CookieLogoutHandler. |
bouncer.CookieResult |
What the cookie handlers pass to the writer: status, rotated token and error. |
bouncer.ErrNoTokenCookie |
The refresh request carried no token cookie (401). |
bouncer.Turnstile |
Cloudflare Turnstile verifier: Secret, Production, Endpoint, Client. |
bouncer.NewTurnstile |
A verifier with the default endpoint and a client with bouncer.TurnstileTimeout. |
bouncer.Turnstile.Verify |
Checks a token for a client IP in open or closed mode; returns a bouncer.TurnstileVerdict and an error for logging. |
bouncer.Turnstile.Middleware |
Middleware that reads the token field, verifies it and answers 503 or 422 through a bouncer.TurnstileWriter. |
bouncer.TurnstileVerdict |
bouncer.TurnstilePassed, bouncer.TurnstileFailed or bouncer.TurnstileUnavailable. |
bouncer.TurnstileWriter |
Writes a Turnstile refusal in the application's envelope; publish one on the app to replace the neutral default. |
bouncer.TurnstileSiteverifyURL |
Cloudflare's siteverify endpoint. |
bouncer.TurnstileTimeout |
5 seconds, the bound on one siteverify call. |
bouncer.TurnstileMaxTokenLength |
2048, Cloudflare's token limit; longer tokens fail without a call. |
bouncer.DefaultTurnstileField |
turnstile_token, the default request field. |
bouncer.HashPassword |
Returns a bcrypt hash at the given cost. |
bouncer.CheckPassword |
Reports whether a password matches a hash. |
bouncer.NeedsRehash |
Reports whether a hash was made below the configured cost. |
Configuration
bouncer.LoadCookieConfig reads these keys; nothing else in bouncer reads config (surf builds the Turnstile verifier from turnstile.secret and turnstile.field, see the surf README). The per-guard token lifetimes (ttl, refresh_ttl) stay with the guard's own config, such as the user plugin's jwt block or admin.jwt.*.
| Key | Default | Controls |
|---|---|---|
auth.cookie.name |
token |
Cookie name; must be a valid cookie name. |
auth.cookie.domain |
empty (host-only) | Cookie domain. Set a parent domain such as example.com to share the cookie between api.example.com and www.example.com; every subdomain then receives it. An invalid domain fails boot. |
auth.cookie.path |
/ |
Cookie path; must start with /. |
auth.cookie.secure |
true when app.url is https |
Sends the cookie over https only. false is refused in the production environment. |
auth.cookie.same_site |
lax |
lax, strict or none; none requires Secure. |
Environment variables follow the compass mapping, for example SUMMER_AUTH__COOKIE__DOMAIN.
Dependencies
- SummerCMS modules: none.
- Third-party:
github.com/golang-jwt/jwt/v5,golang.org/x/crypto/bcrypt. - Standard library:
bytes,context,crypto/rand,database/sql,encoding/hex,encoding/json,errors,fmt,io,log/slog,math,mime,net,net/http,net/url,reflect,regexp,strconv,strings,sync,time. - Tests additionally use
github.com/testcontainers/testcontainers-gowith itsmodules/postgrespackage, andgithub.com/jackc/pgx/v5/stdlib.
Testing
go test ./modules/bouncer/...
The Postgres blacklist concurrency test starts a PostgreSQL container through testcontainers-go and needs Docker. Run go test -short ./modules/bouncer/... to skip it; the remaining tests need no external services (Turnstile tests use an httptest siteverify fake through Endpoint).