Modeling Security/Trust in Haskell

Table of Contents

Any service that accepts arbitrary input needs to pass that input through a series of checks before running its core logic. A very common example of such a service is an HTTP server.

There are 2 recurring layers of checks in almost every system:

  1. Validation (Sanitization): Is the input data in the expected shape?
  2. Authorization: Are you allowed to do this?

The goal of this post is to model these layers so that they can be verified, reasoned about, and reviewed independently - without having to understand the entire system at once.

1. Validation

The first step in establishing trust is checking whether the data sent by the client is in the correct form. Before the rest of the application can safely operate on the input, it must be sanitised (or validated).

We will model this using a Validation type class.

newtype Validated a = Validated a

newtype ValidationError = ValidationError String
    deriving (Show)

data ValidationResult a
    = VRFailed ValidationError
    | VRPassed (Validated a)

class Validation a where
    validate :: a -> ValidationResult a

The key invariant here is simple but powerful:

If the application ever sees a value of type Validated a, then it can trust that the data represented by a has already been checked and is in a valid shape.

This allows us to make invalid states unrepresentable beyond this point.

2. Authorization

The next step in the chain of trust is authorization. Strictly speaking, authentication should come first, but authentication is a prerequisite for the authorization layer we are about to design, so we model them together.

newtype Permitted payload ctx =
    Permitted { permittedCtx :: ctx }

newtype PermissionError = PermissionError String
    deriving (Show)

data PermitDecision payload ctx
    = PDDenied PermissionError
    | PDAllowed (Permitted payload ctx)

class Monad m => Auth m payload where
    type AuthContext payload
    permit ::
        Maybe User ->
        Validated payload ->
        m (PermitDecision payload (AuthContext payload))

This type class is slightly more involved.

The associated type AuthContext is part of the Auth type class, which is possible because Haskell supports type families. The entire authorization machinery is tied to the input type payload. Each payload gets to define its own AuthContext.

The idea behind AuthContext is that authorization often requires building some context - perhaps by querying a database or calling an external service. Constructing this context may be expensive, and it is often needed later by the business logic. Instead of recomputing or discarding it, we carry it forward in a type-safe way.

The Maybe User argument represents authentication. We assume that there exists some mechanism that attempts to extract user identity information from the request and produces a User if successful. Nothing indicates that no user identity could be established - for example, when an HTTP request has no cookies or authorization headers.

One crucial property of this design is that authorization can be performed if and only if validation has already succeeded. This enforces a strict flow of trust through the system.

3. Logic

Now let’s tie the validation and authorization layers into the logic layer.

class Monad m => Logic m payload where
    type LogicResult payload
    logic ::
        Validated payload ->
        Permitted payload (AuthContext payload) ->
        m (LogicResult payload)

At this point, the logic layer can rely on two guarantees:

  1. The input data has been validated.
  2. Authorization has succeeded.

As with AuthContext, the LogicResult is parameterized by payload because different endpoints naturally produce different results.

4. Runner

Putting everything together, we can define a generic endpoint runner:

runEndpoint ::
    forall m payload.
    ( MonadThrow m
    , Validation payload
    , Auth m payload
    , Logic m payload
    ) =>
    Maybe User ->
    payload ->
    m (LogicResult payload)
runEndpoint user payload0 = do
    payload <- vresult throwM pure (validate payload0)
    decision <- permit user payload
    case decision of
        PDDenied perr -> throwM perr
        PDAllowed ctx -> logic payload ctx

This function clearly expresses the intended flow:

  1. Validate the input.
  2. Authorize the request.
  3. Run the business logic.

Each step is isolated, explicit, and independently testable.

5. Example: Creating a Blog Comment

Consider a simple blog platform where users can leave comments on posts.

5.1. Rules

  1. A comment body must be non-empty and at most 500 characters.
  2. Only logged-in users can comment.
  3. Users who are banned may still log in, but are not allowed to post comments.

5.2. Domain Types

data User = User
    { userId :: Int
    }

data PostId = PostId Int

We assume that some user-related information lives in the database.

isUserBanned :: User -> IO Bool

5.3. Endpoint Payload

data CreateComment = CreateComment
    { ccPostId :: PostId
    , ccBody   :: String
    }

5.4. Logic

The logic layer is intentionally boring. It does not perform validation or authorization checks—it relies entirely on the types.

createComment :: User -> PostId -> String -> IO ()

instance Logic IO CreateComment where
    type LogicResult CreateComment = ()
    logic
        (Validated CreateComment{..})
        (Permitted user) =
            createComment user ccPostId ccBody

At this point we know:

  • The comment body is valid.
  • The user is authenticated.
  • The user is allowed to comment.

5.5. Validation

Validation checks only the shape of the input.

instance Validation CreateComment where
    validate val
        | null (ccBody val) =
            VRFailed (ValidationError "Comment cannot be empty")
        | length (ccBody val) > 500 =
            VRFailed (ValidationError "Comment is too long")
        | otherwise =
            VRPassed (Validated val)

No user information is needed here, and no I/O is performed.

5.6. Authorization

Authorization answers the question: is this user allowed to perform this action?

instance Auth IO CreateComment where
    type AuthContext CreateComment = User

    permit Nothing _ =
        pure $ PDDenied (PermissionError "Login required")

    permit (Just user) _ = do
        banned <- isUserBanned user
        if banned
        then pure $ PDDenied (PermissionError "User is banned")
        else pure $ PDAllowed (Permitted user)

This step is allowed to:

  • Perform I/O
  • Query the database
  • Build context that will be reused by the logic layer

6. Final Thoughts

This example closely mirrors real-world systems:

  • Validation: Is the comment text reasonable?
  • Authorization: Is this user allowed to comment?

Most importantly, the business logic never re-checks these conditions. The type system guarantees that by the time logic runs, all trust boundaries have already been crossed.

This keeps the logic simple, predictable, and hard to misuse - exactly the goal of separating validation, authorization, and logic into explicit layers. These layers can evolve independently, making the system easier to maintain, review, and extend over time.

Author: Adithya

Created: 2026-10-16 Fri 02:10

Validate