Skip to content

Authentication & Permissions

A typical setup has two layers:

  1. Authentication — HTTP middleware validates credentials (JWT, session cookie, API key, etc.) and stores the caller's identity on the request context.
  2. 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.

auth/middleware.go
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:

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

schema/schema_post_policy.go
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.

SourceTypical status
Auth middleware — missing or invalid credentials401
Privacy policy — privacy.Deny403
Row not found after policy filtering404

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:

schema/schema_post.go
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:

base-openapi.json
{
"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:

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

llms.txtdocumentation for LLMs