Authentication & Permissions
A typical setup has two layers:
- Authentication — HTTP middleware validates credentials (JWT, session cookie, API key, etc.) and stores the caller's identity on the request context.
- Authorization — Ent privacy policies read that context on every query and mutation the handler runs.
The generated handler injects the ent.Client into the request context via UseEntContext. Privacy
policies run automatically on handler-driven database calls when the
privacy feature is enabled at codegen time.
flowchart LR
Client --> Middleware
Middleware -->|"identity on context"| Handler
Handler -->|"ent.NewContext"| Ent
Ent --> Privacy
Privacy --> DB[(database)]
Return 401 Unauthorized from middleware when credentials are missing or invalid. Reserve 403 Forbidden for requests that are authenticated but not allowed by a privacy policy.
Validate the request before it reaches the generated handler and attach whatever your privacy rules need — user ID, roles, tenant ID, and so on.
type contextKey int
const userKey contextKey = iota
type User struct { ID int Role string}
func UserFromContext(ctx context.Context) (*User, bool) { u, ok := ctx.Value(userKey).(*User) return u, ok}
func WithUser(ctx context.Context, u *User) context.Context { return context.WithValue(ctx, userKey, u)}
func AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if token == "" { http.Error(w, "missing credentials", http.StatusUnauthorized) return }
user, err := validateToken(token) if err != nil { http.Error(w, "invalid credentials", http.StatusUnauthorized) return }
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), user))) })}Mount middleware outside the generated handler so every route — including custom ones you add alongside entrest — sees the same context:
srv, err := rest.NewServer(db, nil)if err != nil { panic(err)}
handler := AuthMiddleware(srv.Handler())http.ListenAndServe(":8080", handler)With chi, apply middleware on the router before calling srv.Handler(r) — see
Configuration — Stdlib vs chi.
You do not need to call UseEntContext yourself when using the generated handler — it wraps the mux
for you. Only register it separately if another handler needs the client on requests that never reach
Server.Handler — see Configuration — Ent client context.
Enable the privacy feature at codegen time (gen.FeaturePrivacy) and implement Policy() on schemas
that need access control. Policy design — rules, filters, row-level scoping, role checks — is covered
in the Ent privacy documentation.
From entrest's perspective, the important part is that policies read the same context values your middleware sets. A minimal example: public reads, authenticated writes.
func (Post) Policy() ent.Policy { return privacy.Policy{ Query: privacy.QueryPolicy{ privacy.AlwaysAllowRule(), }, Mutation: privacy.MutationPolicy{ privacy.ContextQueryMutationRule(func(ctx context.Context) error { if _, ok := UserFromContext(ctx); ok { // You could run additional queries here to validate permissions, // check info from the context on what the users role may be, // and/or use advanced privacy features (documented in the Ent // privacy docs), which allow filtering down resources, (e.g. // only showing public blog posts unless the user is an admin). return privacy.Allow } return privacy.Skip }), privacy.AlwaysDenyRule(), }, }}The handler does not know about your auth model — it runs Ent queries with the request context, and privacy policies decide what is allowed.
When a privacy rule returns privacy.Deny, the generated DefaultErrorHandler responds with
403 Forbidden and the standard error envelope. See
Configuration — Error handling.
| Source | Typical status |
|---|---|
| Auth middleware — missing or invalid credentials | 401 |
Privacy policy — privacy.Deny | 403 |
| Row not found after policy filtering | 404 |
Set MaskErrors in production so internal policy messages do not leak to clients.
Privacy policies run at the database layer. Annotations and Ent field options shrink the attack surface at generation time.
Mark secrets with Ent's Sensitive() so they are omitted from JSON responses and the OpenAPI spec.
Use entrest.WithSkip when a field should never
appear in the REST API but is not marked sensitive.
entrest.WithReadOnly keeps a field out of
create/update bodies. entrest.WithIncludeOperations
and entrest.WithExcludeOperations
control which CRUD endpoints exist — useful for admin-only schemas or edges clients should not
reassign:
edge.From("author", User.Type). Ref("posts"). Unique(). Required(). Annotations( entrest.WithExcludeOperations( entrest.OperationCreate, entrest.OperationUpdate, ), )Set the author in an Ent hook or default instead of letting clients pick any user ID.
entrest.WithAllowClientIDs and
entrest.Config.AllowClientIDs let
clients send an id on create. Only enable this with validation — a deleted resource recreated
with the same ID can otherwise be hijacked.
Authentication headers belong in OpenAPI security schemes, not
GlobalRequestHeaders. Add schemes through
a base spec via SpecFromPath or
Spec:
{ "components": { "securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" } } }, "security": [{ "bearerAuth": [] }]}entrest merges this into the generated spec. DefaultErrorResponses
already documents 401 and 403 on operations. The embedded /docs UI supports
persistAuth when you use the default Scalar handler — see API Docs.
Declaring a scheme in the spec does not enforce it. Middleware and privacy policies do.
Privacy policies also apply to code that never goes through HTTP — migrations, workers, startup
seeding. Use privacy.DecisionContext(ctx, privacy.Allow) to bypass checks for trusted internal
work. See the Ent privacy docs.
Wrap the handler the same way production does, then seed context through middleware or call the API with credentials:
func authAs(user *User, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), user))) })}
func TestCreatePostRequiresAuth(t *testing.T) { ctx := context.Background() db := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_pragma=foreign_keys(1)") t.Cleanup(func() { db.Close() })
srv, err := rest.NewServer(db, nil) require.NoError(t, err)
ts := enttest.WithExisting(t, authAs(&User{ID: 1, Role: "admin"}, srv.Handler()))
resp := ts.Request[ent.Post](ctx, http.MethodPost, "/posts", map[string]any{ "title": "hello world", "body": "content here", }).Must(t) assert.Equal(t, http.StatusCreated, resp.Data.Code)}To assert a denial, skip .Must(t) and check resp.Error.Code — see
Testing — Success and failure.

