All skills
sailscastshq avatar

/authentication

@bf19e10

Authentication patterns for The Boring JavaScript Stack — session-based auth with password, magic links, passkeys (WebAuthn), two-factor authentication (TOTP/email/backup codes), password reset, and OAuth. Use this skill when implementing or modifying any authentication flow in a Sails.js application.

Use this Skill: https://skilld.dev/gh/sailscastshq/boring-stack/authentication

This session only. Nothing lands on disk.

rulespasskeys.md

≈4.4k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Passkey Authentication

Overview

Passkeys provide phishing-resistant, passwordless authentication using the WebAuthn standard. The Boring Stack implements passkeys with the @simplewebauthn/server (backend) and @simplewebauthn/browser (frontend) libraries. Users can register multiple passkeys (fingerprint, face ID, security keys) and use them to log in without a password.

Dependencies

{
  "dependencies": {
    "@simplewebauthn/server": "^11.0.0"
  },
  "devDependencies": {
    "@simplewebauthn/browser": "^11.0.0"
  }
}

The server package runs in Sails.js actions and helpers. The browser package runs in the frontend (React/Vue/Svelte) to interact with the platform authenticator.

User Model Fields

// api/models/User.js (passkey-related attributes)
{
  passkeyEnabled: {
    type: 'boolean',
    defaultsTo: false
  },
  passkeys: {
    type: 'json',
    defaultsTo: []
    // Array of stored credentials:
    // [{
    //   credentialID: 'base64url-encoded-id',
    //   credentialPublicKey: 'base64url-encoded-key',
    //   counter: 0,
    //   transports: ['internal', 'hybrid']
    // }]
  },
  passkeyChallenge: {
    type: 'string'
  },
  passkeyChallengeExpiresAt: {
    type: 'number'
  }
}

Registration Flow

Registration is a two-step process: generate options, then verify the attestation.

Step 1: Generate Registration Options

// api/helpers/passkey/generate-registration-options.js
const { generateRegistrationOptions } = require('@simplewebauthn/server')

module.exports = {
  inputs: {
    user: {
      type: 'ref',
      required: true
    }
  },

  fn: async function ({ user }) {
    const options = await generateRegistrationOptions({
      rpName: sails.config.custom.appName || 'My App',
      rpID: sails.config.custom.passkeyRpId || 'localhost',
      userID: String(user.id),
      userName: user.email,
      userDisplayName: user.fullName,
      // Exclude already-registered credentials to prevent duplicates
      excludeCredentials: (user.passkeys || []).map((passkey) => ({
        id: passkey.credentialID,
        type: 'public-key',
        transports: passkey.transports
      })),
      authenticatorSelection: {
        residentKey: 'preferred',
        userVerification: 'preferred'
      }
    })

    return options
  }
}

Step 1 Controller: Setup Passkey

// api/controllers/security/setup-passkey.js
module.exports = {
  exits: {
    success: {
      responseType: '' // Returns JSON
    }
  },

  fn: async function () {
    const user = await User.findOne({ id: this.req.session.userId })

    const options = await sails.helpers.passkey.generateRegistrationOptions(
      user
    )

    // Store challenge with 5-minute expiry for verification
    await User.updateOne({ id: user.id }).set({
      passkeyChallenge: options.challenge,
      passkeyChallengeExpiresAt: Date.now() + 5 * 60 * 1000
    })

    return options
  }
}

Step 2: Verify Registration

// api/helpers/passkey/verify-registration.js
const { verifyRegistrationResponse } = require('@simplewebauthn/server')

module.exports = {
  inputs: {
    credential: {
      type: 'ref',
      required: true
    },
    expectedChallenge: {
      type: 'string',
      required: true
    }
  },

  exits: {
    success: {
      description: 'Registration verified successfully.'
    },
    invalid: {
      description: 'Registration verification failed.'
    }
  },

  fn: async function ({ credential, expectedChallenge }) {
    const verification = await verifyRegistrationResponse({
      response: credential,
      expectedChallenge,
      expectedOrigin: sails.config.custom.baseUrl,
      expectedRPID: sails.config.custom.passkeyRpId || 'localhost'
    })

    if (!verification.verified || !verification.registrationInfo) {
      throw 'invalid'
    }

    return verification.registrationInfo
  }
}

Step 2 Controller: Verify Passkey Setup

// api/controllers/security/verify-passkey-setup.js
module.exports = {
  inputs: {
    credential: {
      type: 'ref',
      required: true
    }
  },

  exits: {
    success: {
      responseType: 'redirect'
    },
    challengeExpired: {
      responseType: 'badRequest'
    },
    verificationFailed: {
      responseType: 'badRequest'
    }
  },

  fn: async function ({ credential }) {
    const user = await User.findOne({ id: this.req.session.userId })

    // Check challenge expiry
    if (!user.passkeyChallenge || user.passkeyChallengeExpiresAt < Date.now()) {
      throw {
        challengeExpired: {
          problems: [{ form: 'Challenge expired. Please try again.' }]
        }
      }
    }

    // Verify the registration response
    let registrationInfo
    try {
      registrationInfo = await sails.helpers.passkey.verifyRegistration.with({
        credential,
        expectedChallenge: user.passkeyChallenge
      })
    } catch (err) {
      throw {
        verificationFailed: {
          problems: [{ form: 'Passkey verification failed. Please try again.' }]
        }
      }
    }

    // Store the new credential
    const newPasskey = {
      credentialID: registrationInfo.credential.id,
      credentialPublicKey: Buffer.from(
        registrationInfo.credential.publicKey
      ).toString('base64url'),
      counter: registrationInfo.credential.counter,
      transports: credential.response.transports || []
    }

    const updatedPasskeys = [...(user.passkeys || []), newPasskey]

    await User.updateOne({ id: user.id }).set({
      passkeys: updatedPasskeys,
      passkeyEnabled: true,
      passkeyChallenge: null,
      passkeyChallengeExpiresAt: null
    })

    return '/settings/security'
  }
}

Authentication Flow

Authentication is also two steps: generate options (challenge), then verify the assertion.

Step 1: Challenge Passkey

// api/helpers/passkey/generate-authentication-options.js
const { generateAuthenticationOptions } = require('@simplewebauthn/server')

module.exports = {
  inputs: {
    user: {
      type: 'ref',
      description: 'Optional user for specific credential filtering'
    }
  },

  fn: async function ({ user }) {
    const options = await generateAuthenticationOptions({
      rpID: sails.config.custom.passkeyRpId || 'localhost',
      userVerification: 'preferred',
      // If we know the user, limit to their credentials
      ...(user &&
        user.passkeys && {
          allowCredentials: user.passkeys.map((passkey) => ({
            id: passkey.credentialID,
            type: 'public-key',
            transports: passkey.transports
          }))
        })
    })

    return options
  }
}
// api/controllers/auth/challenge-passkey.js
module.exports = {
  inputs: {
    email: {
      type: 'string',
      isEmail: true
    }
  },

  exits: {
    success: {
      responseType: '' // Returns JSON
    }
  },

  fn: async function ({ email }) {
    let user

    // If email provided, generate user-specific challenge
    if (email) {
      user = await User.findOne({ email: email.toLowerCase() })
    }

    const options = await sails.helpers.passkey.generateAuthenticationOptions(
      user
    )

    // Store challenge for verification
    // For known users, store on user record
    // For discoverable credentials, store in session
    if (user) {
      await User.updateOne({ id: user.id }).set({
        passkeyChallenge: options.challenge,
        passkeyChallengeExpiresAt: Date.now() + 5 * 60 * 1000
      })
    } else {
      this.req.session.passkeyChallenge = options.challenge
      this.req.session.passkeyChallengeExpiresAt = Date.now() + 5 * 60 * 1000
    }

    return options
  }
}

Step 2: Verify Passkey

// api/helpers/passkey/verify-authentication.js
const { verifyAuthenticationResponse } = require('@simplewebauthn/server')

module.exports = {
  inputs: {
    credential: {
      type: 'ref',
      required: true
    },
    expectedChallenge: {
      type: 'string',
      required: true
    },
    storedCredential: {
      type: 'ref',
      required: true
    }
  },

  exits: {
    success: {
      description: 'Authentication verified.'
    },
    invalid: {
      description: 'Authentication failed.'
    }
  },

  fn: async function ({ credential, expectedChallenge, storedCredential }) {
    const verification = await verifyAuthenticationResponse({
      response: credential,
      expectedChallenge,
      expectedOrigin: sails.config.custom.baseUrl,
      expectedRPID: sails.config.custom.passkeyRpId || 'localhost',
      credential: {
        id: storedCredential.credentialID,
        publicKey: Buffer.from(
          storedCredential.credentialPublicKey,
          'base64url'
        ),
        counter: storedCredential.counter
      }
    })

    if (!verification.verified) {
      throw 'invalid'
    }

    return verification.authenticationInfo
  }
}
// api/controllers/auth/verify-passkey.js
module.exports = {
  inputs: {
    credential: {
      type: 'ref',
      required: true
    },
    email: {
      type: 'string',
      isEmail: true
    }
  },

  exits: {
    success: {
      responseType: 'redirect'
    },
    invalidPasskey: {
      responseType: 'badRequest'
    }
  },

  fn: async function ({ credential, email }) {
    let user
    let expectedChallenge

    if (email) {
      // Known user flow
      user = await User.findOne({ email: email.toLowerCase() })
      if (!user) {
        throw {
          invalidPasskey: { problems: [{ form: 'Authentication failed.' }] }
        }
      }
      expectedChallenge = user.passkeyChallenge
    } else {
      // Discoverable credential flow: find user by credential ID
      const allUsersWithPasskeys = await User.find({
        passkeyEnabled: true
      })

      for (const candidate of allUsersWithPasskeys) {
        const match = (candidate.passkeys || []).find(
          (pk) => pk.credentialID === credential.id
        )
        if (match) {
          user = candidate
          break
        }
      }

      if (!user) {
        throw {
          invalidPasskey: { problems: [{ form: 'Authentication failed.' }] }
        }
      }

      expectedChallenge = this.req.session.passkeyChallenge
    }

    // Validate challenge expiry
    const challengeExpiry =
      user.passkeyChallengeExpiresAt ||
      this.req.session.passkeyChallengeExpiresAt

    if (!expectedChallenge || challengeExpiry < Date.now()) {
      throw {
        invalidPasskey: {
          problems: [{ form: 'Challenge expired. Please try again.' }]
        }
      }
    }

    // Find the matching stored credential
    const storedCredential = (user.passkeys || []).find(
      (pk) => pk.credentialID === credential.id
    )

    if (!storedCredential) {
      throw {
        invalidPasskey: { problems: [{ form: 'Authentication failed.' }] }
      }
    }

    // Verify the assertion
    let authenticationInfo
    try {
      authenticationInfo =
        await sails.helpers.passkey.verifyAuthentication.with({
          credential,
          expectedChallenge,
          storedCredential
        })
    } catch (err) {
      throw {
        invalidPasskey: { problems: [{ form: 'Authentication failed.' }] }
      }
    }

    // Update counter for replay prevention
    const updatedPasskeys = (user.passkeys || []).map((pk) => {
      if (pk.credentialID === credential.id) {
        return { ...pk, counter: authenticationInfo.newCounter }
      }
      return pk
    })

    await User.updateOne({ id: user.id }).set({
      passkeys: updatedPasskeys,
      passkeyChallenge: null,
      passkeyChallengeExpiresAt: null
    })

    // Clear session challenge if used
    delete this.req.session.passkeyChallenge
    delete this.req.session.passkeyChallengeExpiresAt

    // Full login (passkeys bypass 2FA)
    this.req.session.userId = user.id

    const defaultTeam = await sails.helpers.user.getDefaultTeam(user.id)
    if (defaultTeam) {
      this.req.session.activeTeamId = defaultTeam.id
    }

    const returnUrl = await sails.helpers.returnUrl.get(this.req)
    await sails.helpers.returnUrl.clear(this.req)

    return returnUrl
  }
}

Challenge Management

5-Minute Expiry

Both registration and authentication challenges expire after 5 minutes:

await User.updateOne({ id: user.id }).set({
  passkeyChallenge: options.challenge,
  passkeyChallengeExpiresAt: Date.now() + 5 * 60 * 1000
})

Challenge Cleanup

After successful verification, always clear the challenge:

await User.updateOne({ id: user.id }).set({
  passkeyChallenge: null,
  passkeyChallengeExpiresAt: null
})

Counter Tracking

WebAuthn uses a signature counter to detect cloned authenticators. Each authentication increments the counter:

// After verification, update the stored counter
const updatedPasskeys = (user.passkeys || []).map((pk) => {
  if (pk.credentialID === credential.id) {
    return { ...pk, counter: authenticationInfo.newCounter }
  }
  return pk
})

If an authenticator presents a counter that is lower than or equal to the stored counter, it may indicate a cloned device. The @simplewebauthn/server library handles this check automatically and will reject the response.

Frontend Integration

Registration (React Example)

import { startRegistration } from '@simplewebauthn/browser'
import { router } from '@inertiajs/react'

async function registerPasskey() {
  try {
    // Step 1: Get registration options from server
    const response = await fetch('/security/setup-passkey', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')
          ?.content
      }
    })
    const options = await response.json()

    // Step 2: Create credential with browser/platform authenticator
    const attestation = await startRegistration({ optionsJSON: options })

    // Step 3: Send attestation to server for verification
    router.post('/security/verify-passkey-setup', {
      credential: attestation
    })
  } catch (error) {
    if (error.name === 'NotAllowedError') {
      // User cancelled the registration
      console.log('Registration cancelled')
    } else {
      console.error('Registration failed:', error)
    }
  }
}

Authentication (React Example)

import { startAuthentication } from '@simplewebauthn/browser'
import { router } from '@inertiajs/react'

async function loginWithPasskey(email) {
  try {
    // Step 1: Get authentication options from server
    const response = await fetch('/auth/challenge-passkey', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email })
    })
    const options = await response.json()

    // Step 2: Get assertion from browser/platform authenticator
    const assertion = await startAuthentication({ optionsJSON: options })

    // Step 3: Send assertion to server for verification
    router.post('/auth/verify-passkey', {
      credential: assertion,
      email
    })
  } catch (error) {
    if (error.name === 'NotAllowedError') {
      console.log('Authentication cancelled')
    } else {
      console.error('Authentication failed:', error)
    }
  }
}

Configuration

Relying Party Settings

// config/custom.js
module.exports.custom = {
  baseUrl: 'https://myapp.com',
  appName: 'My App',

  // Passkey settings
  passkeyRpId: 'myapp.com' // Must match the domain (no protocol, no port)
}

The rpID is the domain that the passkey is bound to. In development, use localhost. In production, use the bare domain (e.g., myapp.com, not https://myapp.com).

Development vs Production

Setting Development Production
rpID localhost myapp.com
expectedOrigin http://localhost:1337 https://myapp.com

Routes

// config/routes.js
module.exports.routes = {
  // Passkey authentication (guest)
  'POST /auth/challenge-passkey': { action: 'auth/challenge-passkey' },
  'POST /auth/verify-passkey': { action: 'auth/verify-passkey' },

  // Passkey management (authenticated)
  'POST /security/setup-passkey': { action: 'security/setup-passkey' },
  'POST /security/verify-passkey-setup': {
    action: 'security/verify-passkey-setup'
  }
}

Security Properties

Property How It Works
Phishing resistant Credentials are bound to the rpID domain
No shared secrets Public-key cryptography, no passwords stored
Replay prevention Counter tracking detects cloned authenticators
Challenge freshness 5-minute challenge expiry prevents replay
Multi-device Users can register multiple passkeys
Bypasses 2FA Passkeys are inherently multi-factor (possession + biometric)

Source: SKILL.md on GitHub

2 warnings17d4 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    This skill provides secure and comprehensive authentication patterns for Sails.js applications using The Boring JavaScript Stack, including password, magic link, passkey, and two-factor authentication.

  • Socket17d

    3 alerts: gptAnomaly, gptSecurity

  • Snyk17d

    Risk: LOW · No issues

  • Runlayer7mo

    10/10 files flagged

Signed by skilld at bf19e10. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 days ago.

Activeupdated 8 months ago
Other metadata
metadata
{
  "author": "sailscastshq",
  "version": "1.0.0",
  "tags": "authentication, auth, login, signup, password, magic-link, passkey, webauthn, 2fa, totp, oauth, boring-stack"
}

README badge

README badge for sailscastshq/boring-stack/authentication