Security

Overview

IHP provides several security protections out of the box. Many common web vulnerabilities – such as cross-site scripting (XSS), SQL injection, and cross-site request forgery (CSRF) – are handled automatically by the framework.

This guide explains what IHP protects against automatically and what you need to handle yourself.

Cross-Site Scripting (XSS) Protection

Cross-site scripting (XSS) is a vulnerability where an attacker injects malicious scripts into web pages viewed by other users. If user input is rendered as raw HTML, an attacker could inject JavaScript that steals session cookies, redirects users, or modifies page content.

Automatic Protection via HSX

IHP’s HSX templating system automatically escapes all interpolated values. When you write:

[hsx|<div>{userInput}</div>|]

Any HTML special characters in userInput are escaped before rendering. For example, if userInput contains <script>alert('xss')</script>, HSX will render it as the literal text &lt;script&gt;alert('xss')&lt;/script&gt; – the browser will display it as text, not execute it as code.

This applies to all data types interpolated into HSX, including Text, String, Int, and any type with a Show instance. Attribute values are also escaped:

[hsx|<a href={userProvidedUrl}>Link</a>|]

When You Bypass Escaping

If you need to render raw HTML (for example, HTML generated from Markdown), you can use preEscapedToHtml:

[hsx|<div>{markdownHtml |> preEscapedToHtml}</div>|]

This bypasses XSS protection. Only use preEscapedToHtml with content you have generated yourself or sanitized. Never use it with user-provided input:

-- DANGEROUS: User input rendered as raw HTML
[hsx|<div>{param "comment" |> preEscapedToHtml}</div>|]

-- SAFE: Let HSX escape the input automatically
[hsx|<div>{param "comment"}</div>|]

Similarly, preEscapedTextValue bypasses escaping for attribute values. Use it with caution.

SQL Injection Protection

SQL injection is a vulnerability where an attacker manipulates database queries by inserting malicious SQL through user input. For example, a user could type '; DROP TABLE users; -- into a form field, and if the input is concatenated directly into a SQL query, the database would execute the injected command.

Automatic Protection via QueryBuilder

IHP’s QueryBuilder always uses parameterized queries. User input is sent as separate parameters to PostgreSQL, never interpolated into the SQL string:

-- Safe: email is sent as a parameter, not concatenated into SQL
query @User
    |> filterWhere (#email, userProvidedEmail)
    |> fetchOneOrNothing

All QueryBuilder functions – filterWhere, filterWhereIn, filterWhereLike, and others – use parameterized queries. You do not need to do anything special to be protected against SQL injection when using the QueryBuilder.

Raw SQL Queries

When you use sqlQuery or sqlExec for raw SQL, use ? placeholders for parameters:

-- SAFE: Using parameterized query
users <- sqlQuery "SELECT * FROM users WHERE email = ? AND active = ?" (email, True)

Never concatenate user input directly into a SQL string:

-- DANGEROUS: SQL injection vulnerability
users <- sqlQuery ("SELECT * FROM users WHERE email = '" <> userInput <> "'") ()

The same applies to typedSql, which also uses parameterized queries:

-- SAFE: Parameters are interpolated safely
users <- sqlQueryTyped [typedSql|
    SELECT id, email FROM users WHERE email = ${userEmail}
|]

Cross-Site Request Forgery (CSRF)

Cross-site request forgery (CSRF) is an attack where a malicious website tricks a user’s browser into making an unwanted request to your application. For example, if a user is logged in to your app, a malicious site could include a hidden form that submits a POST request to your app’s “delete account” endpoint.

How IHP Protects Against CSRF

IHP uses the SameSite=Lax cookie attribute on session cookies. This tells the browser to not send the session cookie along with cross-origin form submissions (POST requests). Since the session cookie is not sent, the attacker’s request will not be authenticated, and the action will fail.

The SameSite=Lax policy allows normal top-level navigation (like clicking a link to your site from another site) but blocks cross-origin POST, PUT, and DELETE requests from sending cookies. This protects against all common CSRF attack vectors.

IHP does not use CSRF tokens. The SameSite cookie attribute provides equivalent protection and is supported by all modern browsers.

What You Need to Do

Use POST (or DELETE/PUT/PATCH) requests for any action that modifies data. Do not use GET requests for side effects:

-- Correct: Use a form with POST for state-changing actions
[hsx|
<form method="POST" action={DeletePostAction post.id}>
    <input type="hidden" name="_method" value="DELETE"/>
    <button type="submit">Delete</button>
</form>
|]

-- Wrong: Using a plain link for a destructive action
-- (GET requests are not protected by SameSite=Lax)
[hsx|<a href={DeletePostAction post.id}>Delete</a>|]

The js-delete CSS class is a special case – IHP’s JavaScript helpers will intercept the click and submit a proper DELETE request via a dynamically created form. But for all other side-effect actions, use a form with the appropriate method.

See the Forms guide for more details on form submissions and the js-delete helper.

Session Security

Sessions store small amounts of data (like the current user ID) that persist between requests.

How Sessions Work

IHP stores session data inside a cryptographically signed and encrypted cookie on the client. The encryption key is generated automatically and stored at Config/client_session_key.aes. Internally, IHP uses the clientsession library.

Because the cookie is encrypted and signed, users cannot read or tamper with the session data.

IHP sets the following security flags on session cookies by default:

FlagValueEffect
HttpOnlyTrueThe cookie is not accessible from JavaScript, preventing XSS attacks from stealing session data
SameSiteLaxThe cookie is not sent with cross-origin POST requests, preventing CSRF attacks
SecureAutomaticSet to True when your baseUrl starts with https://, ensuring the cookie is only sent over encrypted connections
Max-Age30 daysThe session expires after 30 days of inactivity
Path/The cookie is available for all paths on the domain

Customizing Session Settings

You can customize the session cookie in Config/Config.hs:

-- Change the session lifetime to 90 days
config :: ConfigBuilder
config = do
    option $ SessionCookie (defaultIHPSessionCookie "https://yourapp.com")
        { Cookie.setCookieMaxAge = Just (fromIntegral (60 * 60 * 24 * 90))
        }

Managing the Session Secret

In production, the session encryption key can be provided via:

Keep the session secret safe. If it is compromised, an attacker can forge session cookies.

Authentication Security

IHP provides a built-in authentication module. See the Authentication guide for setup instructions.

Password Hashing

IHP uses the pwstore-fast library to hash passwords. Passwords are stored as salted hashes using the PBKDF1 algorithm with a strength factor of 17. Each password gets a unique random salt, so identical passwords produce different hashes.

Use hashPassword to hash passwords before storing them:

action CreateUserAction = do
    newRecord @User
        |> fill @'["email", "passwordHash"]
        |> validateField #passwordHash nonEmpty
        |> ifValid \case
            Left user -> render NewView { .. }
            Right user -> do
                hashed <- hashPassword user.passwordHash
                user
                    |> set #passwordHash hashed
                    |> createRecord
                redirectTo UsersAction

Account Lockout

IHP provides automatic account lockout to prevent brute-force attacks. After 10 failed login attempts (by default), the user account is locked for one hour.

The lockout threshold is configurable in your SessionsControllerConfig instance:

instance Sessions.SessionsControllerConfig User where
    maxFailedLoginAttempts _ = 5  -- Lock after 5 attempts instead of 10

This requires your users table to have locked_at and failed_login_attempts columns, which are part of the standard authentication schema.

Timing-Safe Login

When a user provides an email that does not exist, IHP returns the same generic “Invalid Credentials” error message as when the password is wrong. This prevents attackers from enumerating valid email addresses.

Mass Assignment

In some frameworks (like Rails before Strong Parameters), all form fields are automatically assigned to model attributes, meaning an attacker could submit extra fields like isAdmin=true to escalate privileges. IHP does not have this problem — you always explicitly name every field, whether you use fill or param.

Using fill

The fill function reads multiple fields from the request and integrates with IHP’s validation system. Only the fields you list are read:

action UpdateUserAction { userId } = do
    user <- fetch userId
    user
        |> fill @'["firstname", "lastname", "email"]
        |> ifValid \case
            Left user -> render EditView { .. }
            Right user -> do
                user |> updateRecord
                redirectTo ShowUserAction { userId }

The advantage of fill over manually calling param is that it integrates with form validation — if a field is missing or invalid, the error is attached to the record and can be displayed next to the form field. Using param directly works fine too, but you handle validation yourself.

File Upload Security

IHP supports file uploads to local storage and S3-compatible cloud storage. See the File Storage guide for detailed usage.

What IHP Does

What You Need to Do

Environment Variables and Secrets

Storing Secrets

Never hardcode secrets (database credentials, API keys, encryption keys) in your source code. Use environment variables:

-- In your controller or config
import IHP.EnvVar

action SendEmailAction = do
    apiKey <- env @Text "SENDGRID_API_KEY"
    -- Use apiKey...

Common Environment Variables

VariablePurpose
DATABASE_URLPostgreSQL connection string
IHP_SESSION_SECRETSession encryption key
PORTHTTP server port
IHP_BASEURLBase URL of the application (e.g., https://yourapp.com)
IHP_ENVEnvironment (Development or Production)

Deployment

In production, set environment variables through your hosting provider’s configuration panel, a .env file (not committed to version control), or a secrets manager. See the Config guide and Deployment guide for more details.

HTTPS and Secure Headers

HTTPS

IHP does not terminate TLS itself. In production, you should run IHP behind a reverse proxy (such as nginx or Caddy) or a load balancer that handles TLS termination.

When your baseUrl (set via IHP_BASEURL or in Config/Config.hs) starts with https://, IHP automatically sets the Secure flag on session cookies, ensuring they are only sent over encrypted connections.

Make sure your IHP_BASEURL uses https:// in production:

export IHP_BASEURL="https://yourapp.com"

Security Headers

IHP does not set security headers like Strict-Transport-Security, X-Content-Type-Options, or X-Frame-Options by default. You can add these using a custom middleware in Config/Config.hs:

import Network.Wai (ifRequest, modifyResponse, mapResponseHeaders)

config :: ConfigBuilder
config = do
    option $ CustomMiddleware securityHeadersMiddleware

securityHeadersMiddleware :: Middleware
securityHeadersMiddleware application request respond =
    application request $ respond . addHeaders
    where
        addHeaders response = mapResponseHeaders (++ securityHeaders) response
        securityHeaders =
            [ ("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
            , ("X-Content-Type-Options", "nosniff")
            , ("X-Frame-Options", "DENY")
            , ("Referrer-Policy", "strict-origin-when-cross-origin")
            ]

If your reverse proxy (nginx, Caddy, etc.) already adds these headers, you do not need to add them in IHP as well.

Content Security Policy

A Content Security Policy (CSP) tells the browser which sources of content (scripts, styles, images, etc.) are allowed on your pages. This provides an additional layer of defense against XSS attacks.

IHP does not set a CSP header by default. You can add one using the same custom middleware approach shown above:

securityHeaders =
    [ ("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'")
    -- ... other headers
    ]

Adjust the policy to match your application’s needs. If you use inline scripts, external CDNs, or third-party services, you will need to add their domains to the appropriate directives. See the MDN CSP documentation for a full reference.

Security Checklist

Use this checklist to review your application’s security posture: