Passwordless Auth Part 1: HMAC-Signed Magic Link Tokens

Passwords are a liability. They get reused, leaked, phished, and forgotten. For a small SaaS, they also mean building password reset flows, hashing infrastructure, and breach notification procedures. That is a lot of surface area for something your users already hate.

Magic links sidestep all of this. The user enters their email, gets a link, clicks it, and they are in. No password to remember, no credential to steal, no database of hashed secrets waiting to be breached. The email inbox becomes the authenticator -- and your users already have that secured with their own MFA, biometrics, or whatever their provider offers.

This post covers the first half of the implementation: building the cryptographic token that powers those magic links. We will handle signing, verification, expiry, and user management -- all backed by tests. The next post will wire this into HTTP routes, email delivery, and sessions.

Why HMAC tokens instead of JWTs?

JWTs are the default answer for signed tokens, and for good reason -- they are standardized, well-understood, and interoperable. But interoperability is a feature you pay for in complexity, and in a system where you are both the producer and consumer of every token, that tax buys you nothing.

Here is what a JWT library brings: header parsing, algorithm negotiation, claim validation, multiple signature schemes, and a spec that has produced a steady stream of security vulnerabilities over the years (algorithm confusion attacks, "alg": "none", key confusion between HMAC and RSA). That is a lot of moving parts for what amounts to "sign some JSON and check the signature later."

Our needs are simpler:

  • One signing algorithm (HMAC-SHA256)
  • One producer and one consumer (our server)
  • Two claims (email and expiration)
  • No need for third-party verification

For this, a hand-rolled HMAC token is fewer than 40 lines of Clojure, uses only javax.crypto from the JDK (no dependencies), and has zero ambiguity about what algorithm is in use. The format is dead simple: base64(payload).base64(signature). You can read it, reason about it, and audit it in minutes.

That said -- if you ever need tokens that cross service boundaries, or that third parties need to verify, reach for a proper JWT library. The right tool depends on the problem.

The token format

Our tokens follow a two-part structure separated by a dot:

base64url({"email":"user@example.com","exp":1709654400000}).base64url(hmac-sha256-signature)

The left side is the payload -- a JSON object with the user's email and an expiration timestamp in milliseconds. The right side is the HMAC-SHA256 signature computed over the base64-encoded payload. Both sides use URL-safe base64 encoding (no +, /, or = characters), which matters because these tokens live in URLs.

To verify a token, you recompute the signature from the payload using your secret key and compare. If the signatures match, the payload has not been tampered with. Then you check the expiration. Two checks, no ambiguity.

Crypto primitives: HMAC-SHA256 and Base64

Let's start with the low-level building blocks. Java's javax.crypto package gives us everything we need.

(ns myapp.auth.core
  (:require
    [clojure.string :as str]
    [datomic.api :as d]
    [jsonista.core :as json]
    [myapp.db.core :as db])
  (:import
    [java.time Instant]
    [java.util Base64 UUID]
    [javax.crypto Mac]
    [javax.crypto.spec SecretKeySpec]))

(set! *warn-on-reflection* true)

A quick note on *warn-on-reflection*: this tells the Clojure compiler to warn when it cannot resolve a Java method at compile time and would fall back to reflection. Reflection is slow and, in crypto code that may run on every request, worth avoiding. The type hints you will see (like ^bytes and ^String) are how we help the compiler.

Now the HMAC function:

(defn hmac-sha256
  "Compute HMAC-SHA256 of data using secret key."
  [^bytes secret-key ^String data]
  (let [mac (Mac/getInstance "HmacSHA256")
        secret-key-spec (SecretKeySpec. secret-key "HmacSHA256")]
    (.init mac secret-key-spec)
    (.doFinal mac (.getBytes data "UTF-8"))))

Three lines of Java interop, no external dependencies. Mac/getInstance gives us an HMAC engine. SecretKeySpec wraps our raw key bytes into something the JCA (Java Cryptography Architecture) understands. We initialize the engine with our key, then feed it the data. Out comes a byte array -- the signature.

The ^bytes and ^String type hints are not just documentation. Without them, Clojure would use reflection to find the right .init and .getBytes overloads at runtime, which is both slower and noisier in the logs.

Next, base64 encoding and decoding:

(defn base64-encode
  "Base64 URL-safe encode."
  [^bytes data]
  (-> (Base64/getUrlEncoder)
      (.withoutPadding)
      (.encodeToString data)))

(defn base64-decode
  "Base64 URL-safe decode."
  [^String data]
  (-> (Base64/getUrlDecoder)
      (.decode data)))

We use getUrlEncoder / getUrlDecoder rather than the standard variants. Standard base64 uses + and /, which have special meaning in URLs. The URL-safe variant substitutes - and _. We also strip padding (= characters) since the decoder handles unpadded input just fine, and padding adds noise to URLs.

Signing tokens

With the primitives in place, signing is straightforward:

(defn sign-token
  "Create a signed token carrying email, expiration, and a nonce.
   Format: base64(payload).base64(signature)
   Payload: {\"email\": \"...\", \"exp\": <ms>, \"nonce\": \"<uuid>\"}"
  [signing-key email ^Instant expires-at nonce]
  (let [payload-map {:email email
                     :exp (.toEpochMilli expires-at)
                     :nonce (str nonce)}
        payload-json (json/write-value-as-string payload-map)
        payload-b64 (base64-encode (.getBytes payload-json "UTF-8"))
        signature (hmac-sha256 signing-key payload-b64)
        signature-b64 (base64-encode signature)]
    (str payload-b64 "." signature-b64)))

The flow:

  1. Build a map with the email, the expiration as epoch milliseconds, and a one-time nonce (a random UUID). We will return to why the nonce is here in a moment -- it is what turns a forgeable-but-replayable token into a single-use credential.
  2. Serialize it to JSON.
  3. Base64-encode the JSON -- this becomes the left side of the token.
  4. Compute the HMAC-SHA256 of the base64-encoded payload using our signing key.
  5. Base64-encode the signature -- this becomes the right side.
  6. Join them with a dot.

An important detail: we sign the base64-encoded payload, not the raw JSON. This means verification only needs to split on the dot and compare signatures -- no base64 decoding needed until after the signature check passes. Fail fast on the cheap operation.

Verifying tokens

Verification is the mirror image of signing, with two additional checks: signature validity and expiration.

(defn verify-token
  "Verify signed token and extract claims.
   Returns {:email \"...\" :nonce \"<uuid-string>\"} if valid,
   nil if invalid or expired."
  [signing-key token]
  (try
    (let [[payload-b64 signature-b64] (str/split token #"\." 2)]
      (when (and payload-b64 signature-b64)
        ;; Verify signature
        (let [expected-signature (hmac-sha256 signing-key payload-b64)
              expected-signature-b64 (base64-encode expected-signature)]
          (when (= signature-b64 expected-signature-b64)
            ;; Signature valid, check expiration
            (let [payload-json (String. ^bytes (base64-decode payload-b64) "UTF-8")
                  payload (json/read-value payload-json
                                           (json/object-mapper {:decode-key-fn true}))
                  exp-time (long (:exp payload))
                  now (.toEpochMilli (Instant/now))]
              (when (> exp-time now)
                ;; Not expired -- hand the caller the email and the nonce.
                ;; verify-token proves the token is authentic and fresh; the
                ;; nonce lets the caller enforce single use (next chapter).
                {:email (:email payload)
                 :nonce (:nonce payload)}))))))
    (catch Exception _e nil)))

Let's walk through the verification logic:

  1. Split the token on the first dot. We pass 2 as the limit to str/split so that any dots in the signature do not cause extra parts.
  2. Check both parts exist. If the token is malformed (no dot, empty parts), bail early with nil.
  3. Recompute the signature from the payload using our signing key and compare it to the provided signature. If they do not match, someone tampered with either the payload or the signature -- return nil.
  4. Decode the payload and parse the JSON. Only now do we touch the payload contents, after we have confirmed they are authentic.
  5. Check expiration. If the current time is past the expiry, the token is dead -- return nil.
  6. Return the email and the nonce if everything checks out. The signature and expiry are settled here; the nonce travels back to the caller, who is responsible for the one-shot replay check (see the next chapter).

The entire function is wrapped in a try/catch that returns nil on any exception. Malformed base64, invalid JSON, missing keys -- all produce nil. This is a deliberate design choice: from the caller's perspective, a token is either valid (returns a map) or it is not (returns nil). There is no need to distinguish between "expired" and "tampered" and "garbage" -- they all mean "do not authenticate this request."

A note on timing attacks

The sharp-eyed reader might notice we are comparing signatures with =, which is not constant-time. In theory, this leaks information about how many bytes of the signature match. In practice, for magic link tokens with a 15-minute expiry that are used exactly once, this is not a realistic attack vector. The attacker would need to send millions of carefully timed requests to extract one signature, and the token expires before they can finish. If you are building something where timing attacks matter (long-lived API keys, for instance), use MessageDigest/isEqual instead.

Token expiry

Tokens get a 15-minute window. This is a convenience function that wraps sign-token with the expiry logic:

(defn create-magic-link-token
  "Create a signed magic-link token plus the nonce embedded in it.
   Returns {:token <signed-string> :nonce <UUID>}. The caller records the
   nonce server-side before sending the link; verification consumes it once."
  [signing-key email]
  (let [expires-at (-> (Instant/now)
                       (.plusSeconds (* 15 60)))
        nonce (UUID/randomUUID)]
    {:token (sign-token signing-key email expires-at nonce)
     :nonce nonce}))

Why 15 minutes? Long enough that the email can survive a slow mail server or a user who gets distracted. Short enough that a token sitting in someone's inbox is not a long-lived credential. This is a judgment call, not a science -- adjust based on your users' behavior.

Notice that the expiry is baked into the signed payload itself. There is no database lookup needed to check if a token has expired. The token carries its own expiration, and the signature guarantees it has not been modified. This is one of the key advantages of signed tokens: verification is a pure function. No database, no cache, no network call. Just bytes and math.

Why a nonce? The replay problem

A signed token has one weakness an attacker can still exploit: it is valid until it expires, and "valid" means "valid every time." If a magic link leaks -- a forwarded email, a proxy log, a shared inbox -- anyone holding it can log in, again and again, for the full 15-minute window. Expiry shortens that window; it does not close it.

We considered three ways to make a link single-use:

  • Store the whole token server-side and delete it on use. Works, but it throws away the main advantage of signed tokens -- that they are self-contained. Now every issuance is a database write of an opaque blob, and verification is a lookup.
  • Track "consumed" tokens by their signature. Smaller than storing the token, but the signature is long and the semantics are awkward -- we would be indexing on a base64 HMAC.
  • Embed a small random nonce and track that. The token stays self-contained and pure-function-verifiable; we only persist a tiny UUID per issuance and flip it from "unused" to "used" exactly once.

We chose the nonce. create-magic-link-token therefore returns two things: the token to email, and the nonce to record. The signing side is now complete -- the next chapter records the nonce at send time and atomically consumes it at verify time, which is where "single use" actually gets enforced.

User management in Datomic

With tokens handled, we need somewhere to put the users. Datomic makes this pleasantly simple.

Finding a user

(defn find-user-by-email
  "Find user by email address."
  [db email]
  (d/q '[:find ?e . :in $ ?email :where [?e :user/email ?email]] db email))

This is a Datalog query. The . after ?e in the find clause is a scalar binding -- it returns the entity ID directly instead of wrapping it in a set of tuples. If no user exists with that email, it returns nil.

The query takes db (an immutable database value) rather than a connection. This is idiomatic Datomic: functions that read take a database value, functions that write take a connection. Database values are immutable snapshots, so queries are inherently thread-safe and reproducible.

Creating a user

(defn create-user!
  "Create a new user account."
  [conn email]
  (let [user-id (UUID/randomUUID)
        now (Instant/now)]
    @(db/transact* conn
       [{:db/id "temp-user"
         :user/id user-id
         :user/email email
         :user/created-at now
         :user/active? true}])
    user-id))

A few things to note:

  • transact* is a thin wrapper around d/transact that converts java.time.Instant values to java.util.Date, which is what Datomic stores internally. It lets us use the modern Java time API everywhere else.
  • "temp-user" is a temporary ID. Datomic assigns the real entity ID during the transaction. We do not need to track it -- we identify users by their :user/email or :user/id going forward.
  • @ dereferences the future returned by d/transact, making the call synchronous. The transaction either succeeds or throws.
  • :user/email is defined as :db.unique/identity in our schema, which means Datomic enforces uniqueness and will upsert (update rather than duplicate) if we transact a new entity with an email that already exists.

Get or create

The login flow needs a function that finds an existing user or creates one. With Datomic's upsert behavior on unique identity attributes, this is clean:

(defn get-or-create-user!
  "Get existing user or create new one, return email."
  [conn email]
  (let [db (d/db conn)]
    (when-not (find-user-by-email db email) (create-user! conn email))
    email))

If the user exists, we skip the transaction entirely. If they do not, we create them. Either way, we return the email. This function is what the magic link handler will call when a user clicks a valid token -- ensuring they have an account before we create their session.

Testing

Tests are where the design proves itself. Let's walk through the test suite.

Test setup

Tests run against a fresh in-memory Datomic database per test, with a deterministic signing key:

(ns myapp.auth.core-test
  (:require
    [clojure.string :as str]
    [clojure.test :refer [deftest is testing use-fixtures]]
    [datomic.api :as d]
    [myapp.auth.core :as auth]
    [myapp.test-helpers :as h])
  (:import
    [java.time Instant]
    [java.util UUID]))

(use-fixtures :each h/with-test-db)

The with-test-db fixture creates a disposable in-memory Datomic database with the full schema loaded, binds it to a dynamic var, and tears it down after each test. No shared state between tests, no cleanup headaches.

The test signing key is a fixed 32-byte string:

(def test-signing-key
  (.getBytes "test-signing-key-32-bytes-long!!" "UTF-8"))

Deterministic keys in tests mean deterministic results. No randomness, no flakiness.

Base64 correctness

First, we verify our base64 implementation round-trips correctly and produces URL-safe output:

(deftest base64-roundtrip
  (let [data (.getBytes "hello world" "UTF-8")
        encoded (auth/base64-encode data)
        decoded (auth/base64-decode encoded)]
    (is (= (seq data) (seq decoded)))))

(deftest base64-url-safe
  (testing "No +, /, or = in output"
    (let [encoded (auth/base64-encode
                    (byte-array (map unchecked-byte [255 239 191 253 251 247])))]
      (is (not (str/includes? encoded "+")))
      (is (not (str/includes? encoded "/")))
      (is (not (str/includes? encoded "="))))))

The second test is particularly important. The byte sequence [255 239 191 253 251 247] is specifically chosen to produce +, /, and = in standard base64. If URL-safe encoding is working, none of those characters should appear.

Token format and round-trip

(deftest sign-token-format
  (let [token (auth/sign-token h/test-signing-key "test@example.com"
                (.plusSeconds (Instant/now) 3600) (UUID/randomUUID))]
    (is (string? token))
    (let [parts (str/split token #"\.")]
      (is (= 2 (count parts)) "Token should have exactly one dot separator")
      (is (every? #(pos? (count %)) parts) "Both parts should be non-empty"))))

(deftest verify-token-roundtrip
  (let [nonce (UUID/randomUUID)
        token (auth/sign-token h/test-signing-key "user@example.com"
                (.plusSeconds (Instant/now) 3600) nonce)
        result (auth/verify-token h/test-signing-key token)]
    (is (= {:email "user@example.com" :nonce (str nonce)} result))))

The format test confirms structural correctness: it is a string, it has exactly two parts separated by a dot, and neither part is empty. The round-trip test confirms semantic correctness: sign a token, verify it, get back the original email and the nonce -- the caller needs the nonce to enforce single use.

Failure modes

These are the tests that matter most. A signing system is defined as much by what it rejects as by what it accepts.

Expired tokens:

(deftest verify-token-expired
  (let [token (auth/sign-token h/test-signing-key "user@example.com"
                (.minusSeconds (Instant/now) 1) (UUID/randomUUID))]
    (is (nil? (auth/verify-token h/test-signing-key token)))))

Create a token that expired one second ago. Verification returns nil. Simple.

Tampered payload:

(deftest verify-token-tampered-payload
  (let [token (auth/sign-token h/test-signing-key "user@example.com"
                (.plusSeconds (Instant/now) 3600) (UUID/randomUUID))
        [_payload sig] (str/split token #"\.")
        tampered (str
                   (auth/base64-encode
                     (.getBytes "{\"email\":\"evil@example.com\",\"exp\":9999999999999}"
                       "UTF-8"))
                   "." sig)]
    (is (nil? (auth/verify-token h/test-signing-key tampered)))))

This simulates an attacker who intercepts a valid token and replaces the payload with a different email while keeping the original signature. The signature will not match the new payload, so verification fails. This is the core security property of HMAC signatures.

Tampered signature:

(deftest verify-token-tampered-signature
  (let [token (auth/sign-token h/test-signing-key "user@example.com"
                (.plusSeconds (Instant/now) 3600) (UUID/randomUUID))
        [payload _sig] (str/split token #"\.")]
    (is (nil? (auth/verify-token h/test-signing-key (str payload ".AAAA"))))))

Valid payload, garbage signature. Rejected.

Wrong signing key:

(deftest verify-token-wrong-key
  (let [other-key (.getBytes "other-signing-key-32-bytes-long!" "UTF-8")
        token (auth/sign-token h/test-signing-key "user@example.com"
                (.plusSeconds (Instant/now) 3600) (UUID/randomUUID))]
    (is (nil? (auth/verify-token other-key token)))))

A token signed with one key cannot be verified with a different key. This matters for key rotation: if you change your signing key, all outstanding tokens become invalid immediately.

Garbage input:

(deftest verify-token-garbage-input
  (is (nil? (auth/verify-token h/test-signing-key nil)))
  (is (nil? (auth/verify-token h/test-signing-key "")))
  (is (nil? (auth/verify-token h/test-signing-key "not-a-token")))
  (is (nil? (auth/verify-token h/test-signing-key "abc.def.ghi"))))

nil, empty string, no dot separator, too many dots -- all return nil. The try/catch in verify-token earns its keep here. No matter what garbage comes in, the function returns a clean nil rather than throwing.

User management tests

(deftest find-user-not-found
  (let [db (d/db h/*conn*)]
    (is (nil? (auth/find-user-by-email db "nobody@example.com")))))

(deftest create-user-and-find
  (let [user-id (auth/create-user! h/*conn* "new@example.com")
        db (d/db h/*conn*)]
    (is (instance? UUID user-id))
    (is (some? (auth/find-user-by-email db "new@example.com")))))

Create a user, find them by email. create-user! returns a UUID, and find-user-by-email finds the entity.

Duplicate handling is worth calling out:

(deftest create-user-duplicate-upserts
  (auth/create-user! h/*conn* "dup@example.com")
  (auth/create-user! h/*conn* "dup@example.com")
  (let [db (d/db h/*conn*)
        n (d/q '[:find (count ?e) . :in $ ?email :where [?e :user/email ?email]]
            db "dup@example.com")]
    (is (= 1 n) "Should have exactly one entity for the email")))

Because :user/email has :db.unique/identity, Datomic performs an upsert rather than throwing a uniqueness constraint violation. Creating the same user twice results in one entity, not two, and no error. This is a property of Datomic's identity model -- entities are identified by their unique attributes, and transacting the same identity twice merges rather than duplicates.

Idempotent get-or-create:

(deftest get-or-create-user-creates-new
  (let [email (auth/get-or-create-user! h/*conn* "fresh@example.com")
        db (d/db h/*conn*)]
    (is (= "fresh@example.com" email))
    (is (some? (auth/find-user-by-email db "fresh@example.com")))))

(deftest get-or-create-user-idempotent
  (auth/create-user! h/*conn* "existing@example.com")
  (let [email (auth/get-or-create-user! h/*conn* "existing@example.com")]
    (is (= "existing@example.com" email))))

Call it with a new email, get a new user. Call it with an existing email, get the same one back. No errors either way.

What we have now

At the end of this post, we have a complete token-based authentication primitive:

  • sign-token -- creates a signed, time-limited token containing an email address and a one-time nonce
  • verify-token -- validates the signature, checks expiry, and returns the email and nonce, returning nil for any failure
  • create-magic-link-token -- wraps sign-token with a 15-minute expiry and returns both the token (to email) and the nonce (to record)
  • find-user-by-email and create-user! -- user CRUD in Datomic
  • get-or-create-user! -- idempotent user lookup/creation for the login flow
  • A comprehensive test suite covering round-trips, expiry, tampering, wrong keys, garbage input, and user management

The entire implementation is about 60 lines of Clojure with zero external dependencies beyond jsonista (for JSON) and Datomic. The crypto comes from the JDK. The token format is simple enough to explain in one sentence and audit in five minutes.

What we do not have yet: HTTP routes, email sending, sessions, the server-side recording and one-shot consumption of the nonce, and the actual magic link flow that ties it all together. That is Part 2.

All code in this series is from a real production SaaS. If you are building something similar, the patterns here should translate directly -- just swap in your own namespace and database.