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:
- Validation (Sanitization): Is the input data in the expected shape?
- 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 byahas 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:
- The input data has been validated.
- 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:
- Validate the input.
- Authorize the request.
- 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
- A comment body must be non-empty and at most 500 characters.
- Only logged-in users can comment.
- 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.