Encryption & Hashing
Guren provides utilities for encrypting data and hashing passwords securely.
Encryption & Hashing
Guren provides utilities for encrypting data and hashing passwords securely.
APP_KEY
Every Guren application needs an APP_KEY, a base64-encoded 32-byte secret used for encryption, cookie signing, and token signing. Guren uses HKDF to derive separate keys for each purpose, so a single APP_KEY secures all subsystems without sharing raw key material.
Generating a Key
# Generate and print a key
bunx guren key:generate
# Generate and write directly to .env
bunx guren key:generate --write
create-guren-app generates an APP_KEY automatically when scaffolding a new project.
Key Rotation
To rotate your APP_KEY without breaking existing encrypted data or active sessions:
- Move the current
APP_KEYvalue toAPP_PREVIOUS_KEYS - Generate a new
APP_KEY
# .env
APP_KEY=base64:<new-key>
APP_PREVIOUS_KEYS=base64:<old-key>
Multiple previous keys can be comma-separated. Guren will try the current key first, then fall back to previous keys when decrypting data or verifying signatures.
Encryption
The Encrypter class provides AES-256-GCM encryption for sensitive data.
Setup
Create an encrypter with a 32-byte key:
import { Encrypter, generateKey } from '@guren/core'
// Generate a new key
const key = generateKey()
console.log(key) // base64:... (32-byte key)
// Create encrypter
const encrypter = new Encrypter({ key })
// With key rotation support
const rotatedEncrypter = new Encrypter({
key: newKey,
previousKeys: [oldKey],
})
Encrypting Data
// Encrypt any value (objects are JSON-serialized automatically)
const encrypted = encrypter.encrypt({ userId: 1, token: 'abc123' })
// Encrypt a raw string without serialization
const encryptedString = encrypter.encryptString('secret message')
Decrypting Data
// Decrypt (automatically deserializes JSON)
const data = encrypter.decrypt(encrypted)
// Returns: { userId: 1, token: 'abc123' }
// Decrypt a raw string
const message = encrypter.decryptString(encryptedString)
// Returns: 'secret message'
Key Management
import { generateKey, Encrypter } from '@guren/core'
// Generate a cryptographically secure key
const key = generateKey()
// Get the current key
const currentKey = encrypter.getKey()
Store your encryption key securely in environment variables:
# .env
APP_KEY=base64:your-32-byte-key-here
Error Handling
import { Encrypter } from '@guren/core'
try {
const decrypted = encrypter.decrypt(invalidPayload)
} catch (error) {
console.error('Decryption failed:', (error as Error).message)
}
Hashing
Password hashing goes through a PasswordHasher. Three implementations ship:
| Class | Algorithm | Runtime |
|---|---|---|
Hash (alias of DefaultHasher) |
Writes scrypt (Argon2id under algorithm: 'argon2'); verifies whichever format the stored hash carries |
Both |
Argon2Hasher |
Bun.password, Argon2id by default and bcrypt on request |
Bun only |
NodeHasher |
crypto.scrypt |
Any |
Reach for Hash unless you have a reason not to: it is what AuthenticatableModel and ModelUserProvider use by default, and the only one that verifies both formats. NodeHasher also runs on both, since Bun implements node:crypto. Argon2Hasher is the Bun-only one. Applications select the hasher once through createApp({ auth: { hasher } }) rather than constructing it (see Authentication).
Argon2Hasherwas calledScryptHasheruntil 2.23.0, which is not what it writes. The old name still resolves to the same class and is deprecated.
The two formats are not interchangeable: a $scrypt$ hash verifies anywhere, an Argon2id one only where Bun.password exists. Hash writes scrypt for that reason, and reports a hash in the other format through needsRehash().
Creating a Hasher
import { Hash } from '@guren/core'
// Writes scrypt. Verifies scrypt, Argon2id and bcrypt by the stored prefix.
const hash = new Hash()
// Writes Argon2id through Bun.password; throws on a runtime without it.
const argon2 = new Hash({ algorithm: 'argon2' })
To pin an algorithm or its cost parameters, construct Argon2Hasher or NodeHasher directly (see Algorithm Options).
Hashing Passwords
const hashedPassword = await hash.hash('user-password')
// $scrypt$N=16384,r=8,p=1$...
Models extending AuthenticatableModel do this for you: pass a plain password on create() and the model hashes it into the passwordHash column. See Authentication.
Verifying Passwords
The stored hash comes first.
const isValid = await hash.verify(hashedPassword, 'user-password')
That order is the inverse of Bun.password.verify(plain, hashed) and of the standalone verifyPassword(plain, hashed) helper, so it is worth checking at every call site. Both parameters are string, so a swapped call compiles and no type error points at it; the built-in hashers detect the obvious case at runtime and throw a TypeError naming the order.
Most apps never need to call this. If you have an AuthManager configured, a session guard does the lookup and the comparison together, including the dummy hash that keeps a missing account from being distinguishable by response time:
const user = await this.auth.guard('web').validate({ email, password })
if (!user) {
return this.json({ error: 'Invalid credentials' }, { status: 401 })
}
Name the guard. TokenGuard.validate() throws (bearer tokens are not credential-based), so a token-only API issuing a token from an email and password has to reach a session guard, or a ModelUserProvider, explicitly.
Checking If Rehash Needed
if (hash.needsRehash(user.passwordHash)) {
await User.update({ id: user.id }, { password: plainPassword })
}
needsRehash() compares the parameters encoded in the hash against the ones the hasher is configured with, so it reports true after you raise a cost factor; Hash also reports it for a hash in the format it does not write. The session guard calls it after every successful login and rehashes the password in place.
Algorithm Options
Argon2 (Bun only)
const hash = new Argon2Hasher({
algorithm: 'argon2id', // 'argon2i', 'argon2d', or 'argon2id' (default)
memoryCost: 65536, // Memory usage in KiB
timeCost: 3, // Iterations
})
Bcrypt
const hash = new Argon2Hasher({
algorithm: 'bcrypt',
cost: 12, // Log rounds
})
Scrypt (Node)
const hash = new NodeHasher({
cost: 16384, // CPU/memory cost (N)
memory: 8, // Block size (r)
saltLength: 16, // Salt bytes
keyLength: 64, // Output bytes
})
The same scrypt implementation is available as standalone functions, which take the plaintext first — the opposite of PasswordHasher.verify():
import { hashPassword, verifyPassword, needsRehash } from '@guren/core'
const stored = await hashPassword('user-password')
const ok = await verifyPassword('user-password', stored)
Using in Controllers
import { Controller, Hash } from '@guren/core'
export default class AuthController extends Controller {
private hash = new Hash()
async register() {
const { email, password } = await this.validateBody(RegisterSchema)
// AuthenticatableModel hashes `password` into `passwordHash` for you.
const user = await User.create({ email, password })
return this.json({ user })
}
async login() {
const { email, password } = await this.validateBody(LoginSchema)
const user = await User.first({ email })
// Stored hash first. `verify(password, user.passwordHash)` type-checks
// and is wrong.
if (!user || !(await this.hash.verify(user.passwordHash, password))) {
return this.json({ error: 'Invalid credentials' }, { status: 401 })
}
if (this.hash.needsRehash(user.passwordHash)) {
await User.update({ id: user.id }, { password })
}
return this.json({ user })
}
}
Security Best Practices
- Never store plain passwords: always hash passwords before storing.
- Use a strong APP_KEY: run
bunx guren key:generate --writeto generate one. Never commit it to version control. - Don't roll your own crypto: use the provided utilities.
- Rotate keys periodically: use
APP_PREVIOUS_KEYSto rotate without downtime (see Key Rotation). - Let
Hashpick the format: scrypt everywhere, so a column written locally under Bun verifies wherever the app is deployed.
Testing
import { describe, it, expect } from 'bun:test'
import { Encrypter, Hash, generateKey } from '@guren/core'
describe('Encryption', () => {
it('encrypts and decrypts data', () => {
const encrypter = new Encrypter({ key: generateKey() })
const encrypted = encrypter.encrypt('secret')
const decrypted = encrypter.decrypt(encrypted)
expect(decrypted).toBe('secret')
})
})
describe('Hashing', () => {
it('hashes and verifies passwords', async () => {
const hash = new Hash()
const hashed = await hash.hash('password123')
const valid = await hash.verify(hashed, 'password123')
expect(valid).toBe(true)
})
it('rejects invalid passwords', async () => {
const hash = new Hash()
const hashed = await hash.hash('password123')
const valid = await hash.verify(hashed, 'wrong-password')
expect(valid).toBe(false)
})
})