Liking cljdoc? Tell your friends :D

Authentication & JWT

Boundary’s user library handles the full authentication lifecycle: user registration, login, JWT tokens, sessions, and multi-factor authentication.

Prerequisites

export JWT_SECRET="minimum-32-character-secret-key"

The JWT secret must be at least 32 characters. Tests that exercise auth also require this variable:

JWT_SECRET="dev-secret-32-chars-minimum" clojure -M:test:db/h2 :user

Login

curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "secret"}'

Response:

{
  "token": "eyJhbG...",
  "user": {"id": "...", "email": "user@example.com", "role": "user"}
}

Protected routes

Add the auth interceptor to routes that require authentication:

[{:path "/api/profile"
  :methods {:get {:handler 'handlers/get-profile
                  :interceptors ['auth/require-authenticated]
                  :summary "Get current user profile"}}}]

Multi-Factor Authentication (MFA)

MFA uses TOTP (Time-based One-Time Passwords, compatible with Google Authenticator / Authy).

Setup flow

# 1. Start MFA setup (returns a secret + QR code URL)
curl -X POST http://localhost:3000/api/auth/mfa/setup \
  -H "Authorization: Bearer <token>"

# 2. Enable MFA (verify with first TOTP code)
curl -X POST http://localhost:3000/api/auth/mfa/enable \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"secret": "...", "verificationCode": "123456"}'

Login with MFA enabled

curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "secret", "mfaCode": "123456"}'

Disable MFA

curl -X POST http://localhost:3000/api/auth/mfa/disable \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"verificationCode": "123456"}'

Session management

# List active sessions
curl http://localhost:3000/api/auth/sessions \
  -H "Authorization: Bearer <token>"

# Revoke a specific session
curl -X DELETE http://localhost:3000/api/auth/sessions/{session-id} \
  -H "Authorization: Bearer <token>"

CSRF protection

State-changing web requests are protected against Cross-Site Request Forgery by the http-csrf-protection interceptor (in the default stack). A POST/PUT/DELETE/PATCH is validated — and rejected with 403 on a missing or invalid token — when it is either session-authenticated or targets a /web route. This covers the web UI, /web/admin, and any session-authenticated /api route. Token-auth API clients that send only a bearer token (no session cookie) are not CSRF-vulnerable and are not checked.

How a token reaches the browser and back:

  • On a page load the interceptor issues a session-bound token and the shared layout renders <meta name="csrf-token" content="…">.

  • For HTMX requests, a global htmx:configRequest listener copies that token into the X-CSRF-Token header automatically — no per-form work.

  • Plain <form method=post> forms carry it in a hidden __anti-forgery-token field (added via (boundary.platform.core.csrf/hidden-field)).

Unauthenticated flows (login, register, MFA) have no session yet, so the token is bound to a SameSite=Strict csrf-session cookie minted on the page GET and validated on the subsequent POST.

Configure under :boundary/http :security :csrf:

:security {:csrf {:enabled?     true
                  :secret       #or [#env CSRF_SECRET #env JWT_SECRET]
                  :exempt-paths ["/api/v1/payments/webhook"]}}  ; webhooks/callbacks

The secret defaults to JWT_SECRET, so deployments are protected even without an explicit :csrf block. Disable it (:enabled? false) only in test/dev. List endpoints that legitimately cannot send a token (PSP webhooks, OAuth callbacks) under :exempt-paths; a trailing /* matches on a path-segment boundary.

See platform library → CSRF protection for the interceptor internals and token format.

Key security notes

  • JWT_SECRET must be set; missing it causes startup failures (it is also the default CSRF signing secret)

  • CSRF protection is enforced for session-authenticated, state-changing requests — see CSRF protection

  • Internal entities use :password-hash (kebab-case) — never :password_hash

  • The user library enforces account lockout after repeated failed login attempts

See user library for the full reference.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close