Skip to content

Passcode

@voltage/passcode generates short-lived, single-use verification codes whose payloads are encrypted with AES-256-GCM. The code itself is the decryption key — an incorrect or reused code cannot decrypt the payload. Codes are stored in Redis and deleted the moment they are consumed.

The package has no opinion on how the code reaches the recipient. Send it by email, SMS, push notification, or carrier pigeon.

Terminal window
yarn add @voltage/passcode @voltage/event-manager @voltage/logger @voltage/zod redis zod
import { PasscodeModule } from '@voltage/passcode';
import { createClient } from 'redis';
@Module({
imports: [
PasscodeModule.register({
secret: process.env.PASSCODE_SECRET,
client: createClient({ url: process.env.REDIS_URL }),
defaultCodeLength: 6,
defaultLifetime: 600,
}),
],
})
export class AppModule {}
PasscodeModule.registerAsync({
inject: [AppConfiguration, getClientToken()],
useFactory: (config: AppConfiguration, redis: RedisClientType) => ({
secret: config.passcode.secret,
client: redis,
defaultCodeLength: 6,
defaultLifetime: 600,
}),
})

Inject PasscodeManager and call create(). Pass the sensitive data you want to recover later as payload — it is encrypted. Pass anything you need to display in the UI as metadata — it is stored in plaintext.

import { PasscodeManager } from '@voltage/passcode';
@Injectable()
export class AuthService {
constructor(private readonly passcode: PasscodeManager) {}
async initiateEmailVerification(user: User, newEmail: string) {
const { id, code, metadata } = await this.passcode.create({
usage: 'account.verify-email',
description: 'Verify your email address',
payload: { userId: user.id, email: newEmail },
metadata: { email: newEmail },
});
// Send code however you like — email, SMS, etc.
this.mailer.send(VerifyEmailMail, {
recipients: [newEmail],
params: { code, metadata },
});
return id;
}
}

create() returns a GeneratedPasscode:

interface GeneratedPasscode<Metadata> {
id: string; // UUID — use this in URLs and as a lookup key
code: string; // Show this to the user (e.g. in an email)
metadata: PasscodeMetadata<Metadata>;
}

The usage string is an internal namespace that prevents codes issued for one purpose from being accepted for another. A code created with 'account.verify-email' cannot be consumed with 'account.password.reset'. It is never exposed to the client — users only ever see id and code.

payload is encrypted — put sensitive data here: user IDs, temporary passwords, intended email addresses. It is only accessible after a successful consume() call.

metadata is stored in plaintext — put display data here: the email address to show in the UI, the expiry timestamp, a human-readable description. It is accessible via metadata() without consuming the code.

Call consume() with the usage, id (from the URL), and code (from the user’s input). On success it returns the decrypted payload and deletes the Redis entry. On failure it throws.

@Post(':id')
async verify(
@Param('id') id: string,
@Body() body: VerifyBody,
) {
try {
const { userId, email } = await this.passcode.consume<{ userId: string; email: string }>(
'account.verify-email',
id,
body.code,
);
await this.query.for(User).update(userId, { email, isEmailVerified: true });
return this.htmx.redirect('/account');
} catch {
return VerifyForm.throw('code', 'Invalid or expired code');
}
}

Call metadata() to retrieve plaintext metadata without consuming the code. Use it to render the verification form and show context to the user — for example, which email address is being verified. Returns false if the passcode has expired or does not exist.

@Get(':id')
async view(@Param('id') id: string) {
const metadata = await this.passcode.metadata<{ email: string }>(
'account.verify-email',
id,
);
if (metadata === false) {
return this.htmx.redirect('/account');
}
return (
<VerifyEmailView
id={id}
email={metadata.email}
expiresAt={metadata.expiresAt}
/>
);
}

Call regenerate() to issue a new code for the same payload. The old entry is deleted and a new id is generated — update the URL accordingly and resend the code to the recipient.

@Post(':id/resend')
async resend(@Param('id') id: string) {
try {
const { id: nextId, code, metadata } = await this.passcode.regenerate<{ email: string }>(
'account.verify-email',
id,
);
const url = await this.url.generate(VerifyEmailController, { params: { id: nextId } });
this.mailer.send(VerifyEmailMail, {
recipients: [metadata.email],
params: { code, metadata, url },
});
return this.htmx.redirect(url);
} catch {
return this.htmx.redirect(
await this.url.generate(VerifyEmailController, { params: { id } }),
);
}
}
interface PasscodeConfiguration {
/**
* Secret used to derive the per-code encryption key.
* Must be between 6 and 64 characters. Changing this invalidates all existing passcodes.
*/
secret: string;
/** Connected Redis client instance. */
client: RedisClientType;
/** Default code length. Must be between 1 and 32. Defaults to 6. */
defaultCodeLength?: number;
/** Default passcode lifetime in seconds. Must be at least 10. Defaults to 300. */
defaultLifetime?: number;
}