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) |
Delegates to ScryptHasher on Bun, NodeHasher elsewhere |
Both |
ScryptHasher |
Bun.password — Argon2id by default, 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 adapts to wherever it runs. NodeHasher also runs on both (Bun implements node:crypto); ScryptHasher is the Bun-only one.
ScryptHasherproduces Argon2id, not scrypt. The name predates the implementation; onlyNodeHasheruses scrypt.
The two runtimes produce different hash formats, so a hash written under one cannot be verified under the other. That only matters for an app that moves an existing password column between runtimes.
Creating a Hasher
import { Hash } from '@guren/core'
// Runtime-detecting. Takes no options.
const hash = new Hash()
To pin an algorithm or its cost parameters, construct ScryptHasher or NodeHasher directly — see Algorithm Options.
Hashing Passwords
const hashedPassword = await hash.hash('user-password')
// On Bun: $argon2id$v=19$m=65536,t=2,p=1$...
// On Node: $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. Nothing in the framework calls it for you.
Algorithm Options
Argon2 (Bun default)
const hash = new ScryptHasher({
algorithm: 'argon2id', // 'argon2i', 'argon2d', or 'argon2id' (default)
memoryCost: 65536, // Memory usage in KiB
timeCost: 3, // Iterations
})
Bcrypt
const hash = new ScryptHasher({
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 algorithm — it is Argon2id on Bun and scrypt on Node, and it is the only hasher that runs on both.
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)
})
})