Software Engineer
Loading posts...

How we designed and implemented HackPSU's new authentication platform using Next.js, Firebase, and session cookies to secure multiple subdomains with seamless UX.

How we designed and built a comprehensive inventory tracking system for HackPSU using Next.js, React Query, and NestJS to manage thousands of items across multiple event locations.

How I designed and implemented the bulk templated email delivery platform using React, SendGrid, MJML and NestJS for HackPSU organizers.
Feel free to contact me at kanishksachdev@gmail.com
You know what I realized the other day? I'd been working with the same authentication system for months, and I was getting tired of typing my password every time I wanted to test something. Then it hit me: if I'm annoyed by this, what about our actual users?
So naturally, I did what any reasonable developer would do: I decided to learn something completely new and implement WebAuthn passkey authentication. Because why make life easy when you can make it interesting, right?
I'd been hearing about passkeys everywhere but never actually built anything with them. Apple's pushing Face ID, Google's talking about Touch ID, and Microsoft keeps mentioning Windows Hello. It seemed like the future of authentication was happening without me.
Time to fix that.
I wasn't trying to solve some massive user complaint or fix a broken system. Our password-based auth was working fine. But you know that feeling when you see a technology everywhere and think "I should probably understand how this actually works"?
That was me with WebAuthn.
Plus, I'd been reading about how passkeys are supposedly the future of authentication: no more forgotten passwords, no more SMS codes, just biometric magic that works across devices. Sounded too good to be true, which meant it was probably worth investigating.
The goal was simple: let people log into HackPSU with their fingerprint, Face ID, or whatever biometric auth their device supports. And if it worked well enough, maybe we'd learn something useful about the future of web authentication.
Here's what I discovered pretty quickly: WebAuthn isn't like other authentication systems. It's not just "replace password with fingerprint." It's a whole different paradigm that required rethinking how authentication flows work.
The WebAuthn Dance:
The tricky part? WebAuthn has two completely different ceremonies (registration vs authentication), and your API needs to handle both seamlessly. Oh, and the user experience needs to guide people through different flows depending on whether they're new users, existing users with passkeys, or existing users without passkeys.
Yeah, it gets complicated fast.
After way too much experimentation, we landed on a three-tiered approach that actually makes sense:
The Three User Types:
This approach meant we could roll out passkey support gradually without breaking existing workflows. Existing users keep doing what they're doing, but new users get the fancy biometric experience.
Let's start with the fun part—the API that figures out what kind of user we're dealing with:
// /app/api/webauthn/get-options/route.ts
export async function POST(request: NextRequest) {
const { email } = await request.json()
let uid: string
let userExists = false
// The detective work: figure out what kind of user this is
try {
const existingUser = await admin.auth().getUserByEmail(email)
uid = existingUser.uid
userExists = true
// Do they already have passkeys?
const credentialsSnapshot = await db
.collection('users')
.doc(uid)
.collection('webauthn-credentials')
.get()
const hasCredentials = !credentialsSnapshot.empty
if (hasCredentials) {
// Path 2: Existing user with passkeys → authentication flow
const allowCredentials = credentialsSnapshot.docs.map((doc) => ({
id: doc.data().credentialID,
type: 'public-key' as const,
transports: doc.data().transports,
}))
const options = await generateAuthenticationOptions({
rpID: process.env.NEXT_PUBLIC_WEBAUTHN_RP_ID || 'localhost',
allowCredentials,
userVerification: 'preferred',
})
// Store session data for verification
await storeSessionData(options.challenge, uid, 'authentication')
return NextResponse.json({
...options,
flow: 'authentication',
message: 'Use your passkey to sign in',
})
} else {
// Path 3: Existing user without passkeys → require password
return NextResponse.json(
{
error:
'Please sign in with your password first, then add a passkey from your dashboard',
requireAuth: true,
},
{ status: 401 },
)
}
} catch (error) {
// Path 1: New user → create account and passkey
const newUser = await admin.auth().createUser({
email,
displayName: email,
emailVerified: true, // Passkey users are considered verified
})
uid = newUser.uid
const options = await generateRegistrationOptions({
rpName: 'HackPSU Auth',
rpID: process.env.NEXT_PUBLIC_WEBAUTHN_RP_ID || 'localhost',
userName: email,
userID: new TextEncoder().encode(uid),
userDisplayName: email,
attestationType: 'none', // We don't need attestation for this use case
authenticatorSelection: {
residentKey: 'preferred', // Store credentials on device when possible
userVerification: 'preferred', // Use biometrics when available
},
supportedAlgorithmIDs: [-7, -257], // ES256 and RS256
})
await storeSessionData(options.challenge, uid, 'registration')
return NextResponse.json({
...options,
flow: 'registration',
isNewUser: true,
message: 'Create your first passkey to get started',
})
}
}
The magic is in the flow detection. We check if the user exists, then if they have passkeys, and route them to the appropriate ceremony. No guesswork, no weird edge cases, just clean logic that handles every scenario.
Here's something they don't tell you about WebAuthn: you need to store challenge data between the options request and the verification request. But you also need to make sure that data is secure and can't be tampered with.
Our solution? HTTP-only cookies with strict security headers:
// Store WebAuthn session data securely
async function storeSessionData(challenge: string, uid: string, flow: string) {
const cookieStore = await cookies()
cookieStore.set('webauthn-challenge', challenge, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 300, // 5 minutes - short enough to be secure
})
cookieStore.set('webauthn-user-id', uid, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 300,
})
cookieStore.set('webauthn-flow', flow, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 300,
})
}
Five-minute expiry keeps things secure, and HTTP-only means no JavaScript can access these cookies. The WebAuthn ceremony either completes in that window or it fails with no lingering session state to worry about.
The frontend had to handle three different flows gracefully. Here's how we made that work:
// The magic function that handles all three paths
export async function authenticateWithEmail(email: string) {
try {
// Get options - the API figures out which flow we need
const optionsResponse = await fetch('/api/webauthn/get-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
const options = await optionsResponse.json()
if (!optionsResponse.ok) {
if (options.requireAuth) {
return {
success: false,
error:
'Please sign in with your password first, then add a passkey from your dashboard',
requireAuth: true,
}
}
throw new Error(options.error || 'Failed to get authentication options')
}
let credential
// Handle both flows with the same interface
if (options.flow === 'registration') {
credential = await startRegistration(options)
} else {
credential = await startAuthentication(options)
}
// Unified verification endpoint
const verificationResponse = await fetch('/api/webauthn/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential }),
})
const result = await verificationResponse.json()
if (result.verified && result.customToken) {
// Sign in with Firebase
await signInWithCustomToken(auth, result.customToken)
return {
success: true,
message: result.message || 'Success!',
flow: options.flow,
isNewUser: options.isNewUser,
}
} else {
throw new Error(result.error || 'Verification failed')
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
}
}
}
The beautiful part? From the UI's perspective, it's just one function call. The complexity is hidden behind the API, so React components can focus on showing the right loading states and error messages.
For existing users who want to add passkeys, we built a simple dashboard integration:
// Add a passkey to an already-authenticated account
export async function addPasskeyToAccount(idToken: string) {
try {
// Get registration options for authenticated user
const optionsResponse = await fetch('/api/webauthn/add-passkey', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${idToken}`, // Dual auth: cookies OR Bearer token
},
})
if (!optionsResponse.ok) {
throw new Error('Failed to get registration options')
}
const options = await optionsResponse.json()
// Start the registration ceremony
const credential = await startRegistration(options)
// Verify and store the new credential
const verificationResponse = await fetch('/api/webauthn/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential }),
})
const result = await verificationResponse.json()
if (result.verified) {
return {
success: true,
message: 'Passkey added successfully!',
}
} else {
throw new Error(result.error || 'Verification failed')
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to add passkey',
}
}
}
The API endpoint /api/webauthn/add-passkey handles authentication via either session cookies (for our existing subdomain auth) or Bearer tokens (for API calls). This flexibility meant we could add passkey support without breaking our existing authentication flows.
One thing I'm particularly proud of is how we handled API security. Every protected WebAuthn endpoint supports dual authentication methods:
// /app/api/webauthn/add-passkey/route.ts
export async function POST(request: NextRequest) {
try {
// Support both session cookies AND Bearer tokens
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('session')?.value
const authHeader = request.headers.get('authorization')
let decodedToken
if (sessionCookie) {
// Verify session cookie (from our subdomain auth system)
decodedToken = await admin.auth().verifySessionCookie(sessionCookie)
} else if (authHeader && authHeader.startsWith('Bearer ')) {
// Verify ID token (for direct API calls)
const idToken = authHeader.split('Bearer ')[1]
decodedToken = await admin.auth().verifyIdToken(idToken)
} else {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 },
)
}
const uid = decodedToken.uid
const email = decodedToken.email
// Generate registration options for this specific user
const options = await generateRegistrationOptions({
rpName: 'HackPSU Auth',
rpID: process.env.NEXT_PUBLIC_WEBAUTHN_RP_ID || 'localhost',
userName: email,
userID: new TextEncoder().encode(uid),
userDisplayName: email,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
supportedAlgorithmIDs: [-7, -257],
})
// Store session data for verification
await storeSessionData(options.challenge, uid, 'registration')
return NextResponse.json({
...options,
flow: 'registration',
message: 'Create a new passkey for your account',
})
} catch (error) {
console.error('Error generating passkey registration options:', error)
return NextResponse.json(
{ error: 'Failed to generate passkey registration options' },
{ status: 500 },
)
}
}
This dual-auth approach means the WebAuthn system plays nicely with our existing session-cookie-based authentication while also supporting direct API access. Best of both worlds.
Let me be honest, this wasn't a smooth ride from day one. Here are the real lessons learned:
What Worked Great:
What Was Surprisingly Hard:
What went really wrong:
The Performance Numbers:
You know what's cool about how this turned out? The system gracefully degrades for browsers that don't support WebAuthn. We check navigator.credentials availability and only show the passkey options if they're supported:
export function isWebAuthnSupported(): boolean {
return !!(navigator.credentials && navigator.credentials.create)
}
Users on older browsers or unsupported devices just see the normal password login. Users on modern devices get the fancy biometric experience. Everyone's happy.
Here's the real impact of adding WebAuthn to HackPSU:
The Technical Architecture:
What's Next: We're planning to add passkey management (view/delete existing passkeys), better error handling for edge cases, and maybe even conditional UI that adapts based on the user's available biometric options.
Adding passkey authentication to HackPSU taught me that WebAuthn isn't just a cool demo, it's genuinely ready for production use. The user experience is fantastic when it works, the security is rock-solid, and the technology is mature enough to handle real-world edge cases.
The key insights:
Most importantly: users actually love it. There's something magical about signing into a web app with your fingerprint that makes the whole experience feel more modern and secure.
If you're thinking about adding passkey support to your app, I'd say go for it. Just be prepared for a learning curve that's steeper than you expect, but more rewarding than most authentication work you've done.
Got questions about WebAuthn implementation or war stories from your own biometric auth adventures? I'd love to hear them. Building the future of authentication is way more fun when you're not doing it alone.