Part of the PurelyManage series.

PurelyManage handles two categories of sensitive data: IMAP passwords submitted by users during migration, and the session tokens that keep sysadmins logged in. Neither can be stored carelessly. This post covers how both are handled and why each design decision was made the way it was.

Encrypting IMAP Credentials at Rest

The problem with storing passwords

When a user submits IMAP credentials for a migration job, the backend needs to hold onto those credentials until the job actually runs, which might be minutes or hours later depending on the queue. They have to live in the database. The question is in what form.

Storing them in plaintext is not an option. If the database is ever compromised (a breach, a misconfigured backup, a leaked dump), every credential in it is immediately readable. The attacker does not need to break anything; they just read the rows.

Hashing does not help here either. Hashing is one-way by design: you can verify a password but you cannot recover it. imapsync needs the actual password to authenticate against the IMAP server. So the credential has to be reversible, which means encryption.

AES-256-GCM

AES (Advanced Encryption Standard) is a symmetric block cipher. Symmetric means the same key encrypts and decrypts. 256 refers to the key size in bits: 32 bytes. Longer keys mean more possible keys, which means more work for anyone trying to brute-force the key.

GCM stands for Galois/Counter Mode, and this is the part that matters most. AES has several modes of operation: CBC, ECB, CTR, GCM. ECB is broken (identical plaintext blocks produce identical ciphertext blocks, which leaks structure). CBC is better but has no integrity protection: an attacker can flip bits in the ciphertext, the decryption will succeed, but produce garbage data, and you will not know it happened.

GCM solves this with authenticated encryption. Every encrypted message includes an authentication tag (16 bytes). When you decrypt, the tag is verified first. If the ciphertext was tampered with in any way, decryption fails with an error before you ever see any output. This means you cannot silently decrypt corrupted or modified data.

In Node.js this is two lines:

const ALG = 'aes-256-gcm'
const cipher = createCipheriv(ALG, key, iv)
// after encrypt:
const tag = cipher.getAuthTag()  // 16 bytes, appended to ciphertext

And on decrypt:

const decipher = createDecipheriv(ALG, key, iv)
decipher.setAuthTag(tag)  // verification happens automatically on final()

If the tag does not match, decipher.final() throws. No partial data is returned.

The IV

GCM requires an initialization vector (IV), also called a nonce. The IV does not need to be secret but it must be unique per encryption. The same key and IV combination used twice breaks GCM’s security guarantees entirely.

The solution is simple: generate a random 12-byte IV for every encryption.

const iv = randomBytes(12)

12 bytes is the recommended IV length for GCM. At 96 bits of randomness, the probability of a collision across any realistic number of encryptions is negligible.

The IV is stored alongside the ciphertext in the database. It is not sensitive. The attacker needs the key, not the IV, to decrypt. Storing it separately per record means each encrypted value can be decrypted independently without any shared state.

What gets stored

function encrypt(text: string): { enc: string; iv: string } {
  const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex')
  const iv = randomBytes(12)
  const cipher = createCipheriv(ALG, key, iv)
  const data = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
  const tag = cipher.getAuthTag()
  return {
    enc: Buffer.concat([data, tag]).toString('hex'),
    iv: iv.toString('hex')
  }
}

The database stores two columns per credential: source_enc (ciphertext + 16-byte auth tag, hex-encoded) and source_iv (the IV, hex-encoded). The key never touches the database. It lives in the environment variable ENCRYPTION_KEY, a 64-character hex string (32 bytes), set once at deployment.

The write-once model

The bigger design decision is what happens after save. The answer is: nothing gets read back.

When a job is returned to the frontend, the encrypted columns are explicitly deleted before the response goes out:

delete job.source_enc
delete job.source_iv
delete job.dest_enc
delete job.dest_iv

There is no API endpoint that returns a decrypted credential. There is no “show password” button. If a user wants to verify their credentials they submit a test connection, which decrypts internally and checks the IMAP login without ever surfacing the password. If they want to update credentials they overwrite: a new encryption of the new value replaces the old one.

This limits the blast radius of a frontend bug, a misconfigured CORS policy, or a session hijack. Even if an attacker gets a valid session token, they cannot extract credentials through the API because the API does not expose them.

The only place decryption happens is inside runJob(), on the server, immediately before spawning the imapsync process. The plaintext credential exists in memory for the duration of that function and nowhere else.


JWT Authentication

Access tokens and refresh tokens

Sessions in PurelyManage use two tokens with different lifetimes:

  • Access token: 15 minutes, signed with JWT_SECRET
  • Refresh token: 30 days (sliding), signed with JWT_REFRESH_SECRET

The split exists because JWTs are stateless by default. Once issued, a token is valid until it expires. There is no “revoke this token” mechanism on the token itself. If someone steals a JWT, it is valid until it expires.

A 15-minute access token limits the damage window. If it leaks, it is useless in 15 minutes. The refresh token lives longer but is only sent to one endpoint (/auth/refresh) and is validated against the database on every use, which gives revocation control.

Two separate secrets means a compromised JWT_SECRET does not expose refresh tokens, and vice versa.

Sliding window sessions

The refresh token is set to 30 days, but 30 days from when? A hard expiry from login time means an active user gets kicked out after 30 days regardless of usage. That is a poor experience and forces unnecessary re-authentication.

The better model is a sliding window: every time the refresh token is used, the expiry is pushed forward by 30 days.

await sql`
  UPDATE sessions
  SET expires_at = NOW() + INTERVAL '30 days', last_seen_at = NOW()
  WHERE id = ${session.id}
`

A session that is used daily never expires. A session that goes unused for 30 consecutive days expires naturally. This matches the actual threat model: the risk of a stolen token grows with time since last use, not time since creation.

Sessions in the database

Refresh tokens are stored in a sessions table alongside the user agent, IP address, and last_seen_at timestamp. This is what makes revocation possible.

When a refresh request comes in, the token is verified cryptographically first (JWT signature check), then looked up in the database:

const [session] = await sql`
  SELECT * FROM sessions
  WHERE refresh_token = ${refreshToken} AND expires_at > NOW()
`
if (!session) return reply.status(401).send({ error: 'Session expired or revoked' })

If the session row does not exist, the token is rejected regardless of its cryptographic validity. This means logging out actually invalidates the token: the row is deleted and the token can never be used again even if it has not expired yet. It also means an admin can see every active session and revoke any of them from the Settings page.

Password hashing

Admin passwords are hashed with bcrypt at cost factor 12 before storage. bcrypt is deliberately slow, making brute-force attacks against leaked hashes expensive. Cost 12 means roughly 250ms per hash on modern hardware, which is acceptable for login but painful for an attacker trying millions of guesses.

const hash = await bcrypt.hash(body.data.password, 12)
// verify:
const valid = await bcrypt.compare(body.data.password, user.password)

bcrypt includes a random salt in the output, so two identical passwords produce different hashes. There is no additional salt step needed.


The next post covers the deletion queue: what happens when a sysadmin tries to delete a user or domain, and why immediate execution is the wrong default.