--- title: Authentication description: JWTs with bouncer, cookie transport, guards as route middleware, a jti blacklist, Cloudflare Turnstile checks and bcrypt passwords. section: services order: 50 --- # Authentication WinterCMS reads the current user through the `Auth` and `BackendAuth` facades, and API plugins add a JWT layer on top. SummerCMS has no facades: [bouncer](../../modules/bouncer/README.md) turns a request into a `bouncer.Principal` through a guard, stores it on the request context, and issues and checks the tokens. The frontend user model and its login endpoints belong to the application's user plugin; bouncer supplies the building blocks. ## Tokens bouncer issues HS256 JSON Web Tokens for two audiences: frontend users (`bouncer.AudienceUser`) and admins (`bouncer.AudienceBackend`). `bouncer.Mint` signs a frontend token for a subject, the user ID as a string, and returns the token and its random `jti`. `bouncer.MintAudience` signs one for any audience. `bouncer.VerifyClaims` checks the signature with HS256 pinned, requires `exp` and `sub`, and returns the claims; `bouncer.Verify` returns only the subject. Both accept frontend tokens, including older tokens without an audience claim. `bouncer.VerifyClaimsAudience` requires the audience you name, so a frontend token never passes an admin check and the other way round: ```go src=modules/bouncer/example_test.go#ExampleMint const issuer = "http://127.0.0.1:8080/api/login" token, _, err := bouncer.Mint(testSecret, "42", issuer, time.Hour) if err != nil { fmt.Println(err) return } sub, iat, exp, _, err := bouncer.VerifyClaims(token, testSecret) fmt.Println(sub, exp.Sub(iat), err) // A frontend token never passes a backend check, and a wrong secret fails. _, _, _, _, err = bouncer.VerifyClaimsAudience(token, testSecret, bouncer.AudienceBackend) fmt.Println(err != nil) _, err = bouncer.Verify(token, "another-secret-with-at-least-32-bytes") fmt.Println(err != nil) // Refresh reissues the token and blacklists the old jti after the grace. bl := bouncer.NewMemoryBlacklist() fresh, err := bouncer.Refresh(testSecret, token, 14*24*time.Hour, bl, 0, issuer) fmt.Println(fresh != token, err) _, _, _, jti, _ := bouncer.VerifyClaims(token, testSecret) revoked, _ := bl.IsBlacklisted(context.Background(), jti) fmt.Println(revoked) // Output: // 42 1h0m0s // true // true // true // true ``` The secret in these examples is a test value. In an application, read the signing secret from configuration set through an environment variable, use at least 32 random bytes and never commit it. ## Refreshing and revoking `bouncer.Refresh` reissues a token while its `iat` is inside the refresh window, even when it has expired, and blacklists the old `jti` after a grace period, so requests already in flight with the old token still succeed. `bouncer.RefreshAudienceFor` also reloads the user and refuses one who was deleted, or whose tokens were issued before `bouncer.Principal.TokensValidAfter`, with `bouncer.ErrSubjectRejected`. The rules match the PHP jwt-auth library, so tokens issued by a WinterCMS application keep working after a port. Revoked token IDs are kept in a `bouncer.BlacklistStore`: - `bouncer.NewMemoryBlacklist` keeps them in the process, for tests. - `bouncer.NewPostgresBlacklist` keeps them in a table you name, with `jti`, `expires_at` and `valid_until` columns. It rejects table names that are not plain identifiers. Logging out is blacklisting the token's `jti`. Setting a user's `TokensValidAfter` to now revokes all their tokens at once, for example after a password change. ## Cookie transport A browser frontend can keep the token in an httpOnly cookie instead of JavaScript-readable storage. The cookie attributes come from one block, `auth.cookie.*`, which `bouncer.LoadCookieConfig` reads into a `bouncer.CookieConfig` at boot: ```yaml cookie: domain: example.com same_site: lax ``` This fragment belongs in `config/auth.yaml`. With the block unset the cookie is named `token`, has `Path=/`, `HttpOnly` and `SameSite=Lax`, no `Domain` (host-only) and `Secure` exactly when `app.url` is https. The keys: | Key | Default | Controls | |-----|---------|----------| | `auth.cookie.name` | `token` | Cookie name. | | `auth.cookie.domain` | empty (host-only) | A parent domain shares the cookie with its subdomains. | | `auth.cookie.path` | `/` | Cookie path. | | `auth.cookie.secure` | true when `app.url` is https | In the production environment a value that resolves to false is refused, whether `false` is set or inferred from an http or unset `app.url`. | | `auth.cookie.same_site` | `lax` | `lax`, `strict` or `none`; `none` requires Secure. | A `bouncer.CookieConfig` built by hand instead of through `bouncer.LoadCookieConfig` gets `SameSite=Lax` when `SameSite` is unset, and the zero config writes `token` on `/` with `HttpOnly`, `SameSite=Lax` and `Secure`; a config with any field set keeps its own `Secure` value. An invalid domain, `same_site: none` without Secure, or a cookie that is not Secure in production makes `bouncer.LoadCookieConfig` return an error naming the key. In production that covers `secure: false` and an unset `auth.cookie.secure` with an http or missing `app.url`; that error names `auth.cookie.secure` and `app.url`, and you fix it by setting `app.url` to the public https URL or `auth.cookie.secure: true`. Return the error from your plugin's boot so the application does not start with a cookie browsers would drop or send in clear. `bouncer.CookieConfig.TokenCookie` builds the cookie for a token and a lifetime. Pass the guard's refresh window (`refresh_ttl`, 20160 minutes by default), not its access `ttl`: the access token inside expires after `ttl`, the frontend's 401 handling then calls the refresh endpoint, and that endpoint needs the cookie to still be there. A cookie that died with the token would force a new sign-in every hour. `bouncer.CookieConfig.ForgetTokenCookie` builds the matching cookie that deletes it; it carries the same name, path and domain, or the browser would keep the original. `bouncer.CookieRefreshHandler` is a ready-made refresh endpoint. It reads the token from the cookie (only from the cookie), calls your refresh func, which wraps `bouncer.Refresh` or `bouncer.RefreshAudienceFor` and so rotates the token and blacklists the old `jti`, sets the rotated cookie and passes a `bouncer.CookieResult` to your writer, which answers in the application's own envelope. A missing cookie (`bouncer.ErrNoTokenCookie`) or a refused refresh reaches the writer with status 401 and leaves the cookie alone. `bouncer.CookieLogoutHandler` revokes the cookie's token through your revoke func, always clears the cookie and answers 200. The [bouncer README](../../modules/bouncer/README.md) has a complete example. **Subdomains.** For a frontend on `www.example.com` calling an API on `api.example.com`, set `auth.cookie.domain: example.com`, list `https://www.example.com` in the API's CORS origins with `supports_credentials: true` (see [Routing](routing.md#cors)), and send requests with credentials. A `Domain` cookie reaches every subdomain of `example.com`, so every host under it is inside the trust boundary: an application on another subdomain receives the token too. Keep the default host-only cookie when frontend and API share one host. **CSRF.** CORS controls who may read a response, not who may send a request. `SameSite=Lax` treats sibling subdomains as the same site, so a page on any `*.example.com` host can make the browser send the cookie. Mount the refresh and logout handlers on POST routes only, and for routes that act on the cookie prefer a JSON body or a custom header, which a cross-site form cannot send without a CORS preflight. The refresh answer is unreadable to an origin CORS does not allow, and a forced logout is the worst a cross-site POST to the logout route can do. ## Guards and middleware A guard implements `bouncer.Guard`: it turns a request into a principal or an error. `bouncer.NewJWTGuard` is the frontend guard. It reads the bearer token, then any cookie names you give it, verifies the token, checks the blacklist and the user's cutoff, and loads the user through your `bouncer.UserProvider`. On failure it answers 401 with `{"error":true,"message":...}`. `bouncer.NewBackendJWTGuard` is the same for the admin audience. The admin API's `backend` guard is composite: an `Authorization: Bearer` value that starts with `g15_` is looked up as a personal API token (see [API tokens](../backend/users-and-permissions.md#api-tokens)); every other request uses the unchanged JWT path (Bearer JWT, then the admin cookie). A `g15_` value is never read from a cookie or the query string. Register guards in a `bouncer.Registry` under a name, and turn one into middleware with `bouncer.Registry.Middleware`. The middleware stores the principal on the context, where handlers read it with `bouncer.User`: ```go src=modules/bouncer/example_test.go#ExampleNewJWTGuard guards := bouncer.NewRegistry() guard := bouncer.NewJWTGuard(testSecret, users{}, bouncer.NewMemoryBlacklist(), "token") if err := guards.Register("acme.blog", "acme.auth", guard); err != nil { fmt.Println(err) return } auth, err := guards.Middleware("acme.auth") if err != nil { fmt.Println(err) return } me := auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, _ := bouncer.User(r.Context()) fmt.Fprintf(w, "user %d, locale %s", user.ID, user.PreferredLocale) })) token, _, _ := bouncer.Mint(testSecret, "42", "http://127.0.0.1:8080/api/login", time.Hour) for _, set := range []func(*http.Request){ func(r *http.Request) {}, func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }, func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "token", Value: token}) }, } { req := httptest.NewRequest("GET", "/api/me", nil) set(req) rec := httptest.NewRecorder() me.ServeHTTP(rec, req) fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String())) } // Output: // 401 {"error":true,"message":"Token not provided"} // 200 user 42, locale pl // 200 user 42, locale pl ``` To protect routes, return that middleware from `pact.HasMiddleware` under a name and put the name on a route group. [Routing](routing.md) shows the complete auth group. A guard that does not implement `bouncer.UnauthorizedWriter` lets an unauthenticated request through without a principal, for routes that behave differently for guests. A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`. ### Accepting admin tokens on plugin routes Plugin routes that should accept an admin JWT or a personal API token call `cabana.ResolveBackend`. It reads only the Authorization Bearer header (never the admin cookie), applies the token's method filter, and for a scoped token requires a method-allowed scope that is full or matches every cover code you pass. On success it returns the request with `bouncer.WithUser` (and `bouncer.WithCredential` for a token). Failures are `cabana.ErrUnauthenticated` or `cabana.ErrTokenScope`. Routes registered before `cabana.Activate` must call it per request, because the resolver is published at activation. ```go src=modules/cabana/example_tokens_test.go#ExampleResolveBackend handler := func(app *backpack.App, w http.ResponseWriter, r *http.Request) { _, _, err := cabana.ResolveBackend(app, r, "acme.blog.*") if err != nil { if errors.Is(err, cabana.ErrTokenScope) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) fmt.Fprint(w, `{"error":"Insufficient permissions"}`) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) fmt.Fprint(w, `{"error":"Authentication required"}`) return } w.WriteHeader(http.StatusOK) } rec := httptest.NewRecorder() r := httptest.NewRequest(http.MethodPost, "/_blog/api/v1/posts", nil) r.Header.Set("Authorization", "Bearer unused") handler(nil, rec, r) fmt.Println(rec.Code, rec.Body.String()) // Output: // 401 {"error":"Authentication required"} ``` ## Turnstile [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) keeps bots off guest and registration endpoints. The frontend widget produces a token, the request carries it, and the server checks it with Cloudflare's siteverify endpoint. surf registers the check as named middleware with two modes: | Middleware | Without a secret | With a secret | |------------|------------------|---------------| | `turnstile:open` | passes (a development no-op) | the token must verify | | `turnstile:closed` | 503 in the production environment, passes elsewhere | the token must verify | Use `turnstile:closed` where an unprotected endpoint would be a hole, such as guest entry: a production deployment that forgot the secret answers 503 instead of letting every bot in. Use `turnstile:open` where the endpoint has other protection and must keep working without Turnstile, such as registration. With a secret set both modes behave the same: a missing token, a token Cloudflare rejects, a token longer than 2048 characters, a failed siteverify call or one slower than 5 seconds answers 422. The middleware reads the token from the `turnstile_token` field (`turnstile.field` changes the name): the query string first, then a JSON object body or a form body. It reads the body and restores it, so the handler sees the same bytes. A multipart body is not searched; put the token in the query string there. | Key | Default | Controls | |-----|---------|----------| | `turnstile.secret` | empty | The site's secret key. Set it through `SUMMER_TURNSTILE__SECRET`; a PHP application's `TURNSTILE_SECRET` value moves to that variable. | | `turnstile.field` | `turnstile_token` | The request field carrying the token. | Refusals are written with neutral bodies, `{"error":"turnstile_unavailable"}` (503) and `{"error":"turnstile_failed"}` (422). To keep an existing response envelope, publish a `bouncer.TurnstileWriter` on the app in your plugin's `Register`; it receives the status and the `bouncer.TurnstileVerdict`. A published `*bouncer.Turnstile` replaces the verifier built from config, for example to give it its own `http.Client`. Cloudflare's [testing page](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) lists secrets that always pass (`1x0000000000000000000000000000000AA`) or always fail (`2x0000000000000000000000000000000AA`), for staging environments and manual checks. A token is valid for 300 seconds and only once: verify it before any write and never retry a verification with the same token. The middleware runs before the handler, so a request with both a bad token and invalid fields gets the Turnstile answer, not the validation errors. When a ported endpoint must keep the order it had, such as validating a name first, call `bouncer.Turnstile.Verify` from the handler instead of using the middleware: it takes the context, token, client IP and mode and returns the verdict, plus an error for logging that never contains the secret or the token. ## Passwords `bouncer.HashPassword` hashes with bcrypt at the cost you give it, `bouncer.CheckPassword` compares in constant time, and `bouncer.NeedsRehash` reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working: ```go src=modules/bouncer/example_test.go#ExampleHashPassword hash, err := bouncer.HashPassword(10, "correct horse battery staple") if err != nil { fmt.Println(err) return } fmt.Println(bouncer.CheckPassword(hash, "correct horse battery staple")) fmt.Println(bouncer.CheckPassword(hash, "wrong")) // After raising the configured cost, rehash on the next successful login. fmt.Println(bouncer.NeedsRehash(hash, 12)) // Output: // true // false // true ``` Rehash a password on the next successful login when `bouncer.NeedsRehash` reports true. Admin sign-in, admin permissions and the admin user commands are covered in [Users and permissions](../backend/users-and-permissions.md).