Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
163758 stories
·
33 followers

How to Build Passkey Authentication with WebAuthn, Next.js and NestJS

1 Share

Passwords are a pain for both users and service providers․ Users often find it difficult to remember passwords, and the risk of phishing and password reuse across multiple websites is high.

Developers have to hash passwords, salt them, rate-limit login attempts, and build "forgot password" flows that nobody enjoys using.

Fortunately, passkeys address many of these issues․ Instead of providing a password‚ users can confirm their identity with a different type of authenticator they use on other devices‚ like fingerprints, facial recognition, or a PIN․ The authenticator signs a challenge using a cryptographic key stored on the device and is never shared with others․

This tutorial shows you how to build an app that doesn't use traditional passwords. The backend is a NestJS API that stores users and their passkeys in PostgreSQL. The frontend is a Next.js app where users sign up and sign in with nothing but an email address and a passkey.

You'll use SimpleWebAuthn, a well-maintained TypeScript library to simplify tasks involving WebAuthn cryptography and data parsing․

By the end of this article, you should have what you need to implement passkey registration and authentication for your website and validate passkeys on your server securely. You'll also learn about potential risks and problems with passkeys that could impact your app or user accounts so you can take steps to prevent them.

You can find the complete source code on GitHub.

Table of Contents

The Problems with Password-Based Authentication

Before we write a single line of code, we need to clearly define what we're replacing.

For decades, password-based authentication has been the default login method. And it has proven its effectiveness, which is why it became the standard. But the model it relies on (a secret that a person must memorize, enter, and keep to themselves) has significant limitations. Every aspect of this model is problematic, as we'll now see.

1. Phishing Attacks

A password will work anywhere it's entered. If an attacker builds a page that's identical to your bank's login page, then the password will work just as well on that page. The only thing stopping the user from being compromised is the fact that they might notice that the URL looks a little odd.

That's a lot to ask. Phishing pages are often very close to being pixel-perfect, and they come in the form of urgent emails or text messages designed to get you to act immediately. Even a very careful, technically minded user can be fooled.

One-time passwords sent via SMS or from an authenticator app are of course useful, but people can still be tricked into typing those in on a fake page.

2. Password Reuse

Most people have dozens, if not hundreds of online accounts. You can't possibly remember a unique, strong password for each one, so you'll invariably reuse the same few passwords across the board.

Attackers know this. If one site is breached, they'll take the email and password pairs that have been leaked and try them automatically against other sites. That's credential stuffing. A breach at a little forum you've long since forgotten can result in someone getting into your email or your bank account.

3. Data Breaches

Every site that uses passwords has to store something that can be used to verify the password. The ideal situation is for the site to store salted hashes rather than the passwords themselves. But not all sites do this well, and hashes of weak passwords can still be cracked offline once an attacker has a copy of the database.

This is clearly the developers' burden. You have to choose the right hashing algorithm, tune it, protect the database, and still treat a leak as a disaster for your users.

4. Lack of Privacy

With passwords, users pass a shared secret to every site they sign up with. They have to trust that site to protect it, and they have no control over what happens to it once it's been breached.

As people reuse the same email and password combinations, it's possible for leaked credentials to be matched across breaches. That, in turn, makes it much easier to link accounts across services and build up a profile of a person's online behaviour.

5. Credential Fatigue

Then there's the day-to-day frustration. You're constantly juggling password policies (at least one symbol, no more than 16 characters, and so on), forced resets every few months, security questions, verification codes...the list goes on. Every new account you create just adds to the pile.

This fatigue leads people straight into the behaviour that makes passwords so insecure: short passwords, small variations of the same password, and passwords written on sticky notes. It also costs the business. People drop off during the sign-up process, get locked out, and then contact support to reset passwords they've forgotten.

How Passkeys Solve These Problems

Passkeys remove the shared secret entirely. passkeys.dev describes them as intuitive, automatically unique, breach-resistant, and phishing-resistant.

Here's how that lines up with each problem:

Problem How passkeys solve it
Phishing attacks The browser and operating system only use a passkey on the site that created it. A fake domain doesn't get anywhere, no matter how convincing it looks.
Password reuse Every passkey is a new key pair created for one site, so there's nothing to reuse.
Data breaches The server only stores a public key. A leaked database gives attackers nothing they can sign in with.
Lack of privacy Nothing secret is shared with the site, and keys are different for every site. Fingerprint and face data don't leave the device.
Credential fatigue There's nothing to create or remember. Users confirm with the same fingerprint, face, or screen lock they use multiple times a day.

The fundamental shift concerns where trust lives. With passwords, security relies on users’ ability to spot fraudulent sites and choose strong credentials. With passkeys, it's the browser and the operating system that enforce the rules, while the user just confirms their identity.

Prerequisites

To follow along, you should be comfortable with TypeScript and have some React experience. You don't need to know NestJS well, but it helps to know what a controller and a service are.

You'll also need these tools installed:

  • Node.js version 22 or later
  • PostgreSQL. On macOS, Postgres.app is the easiest option. pgAdmin is handy if you prefer a graphical tool.
  • A device that supports passkeys. Almost any modern laptop or phone works: a Mac with Touch ID, a Windows PC with Windows Hello, or an Android phone or iPhone.

What Are Passkeys?

A passkey is a credential based on public key cryptography. When you create one, your device generates a key pair. The private key stays on your device, locked behind your fingerprint, face, or screen lock. The public key goes to the website, which stores it next to your account.

Passkeys are built on the Web Authentication API (WebAuthn), a W3C standard supported by all major browsers. The term "passkey" is the friendly name the industry settled on for WebAuthn credentials that can sync between devices. If you want a deeper introduction, passkeys.dev is an excellent resource.

One detail often surprises developers: your app can't choose between a fingerprint, facial recognition, or a PIN code. It's the operating system that decides, based on the device's capabilities and the settings configured by the user.

Here's what users will typically see:

Device Verification method
Mac Touch ID, or the Mac password
iPhone or iPad Face ID, Touch ID, or the passcode
Android Fingerprint, face unlock, or PIN, pattern, or password
Windows Windows Hello face, fingerprint, or PIN
Any other computer Scan a QR code and confirm on your phone

What your server can do is insist that some local check happened. You'll see how in a moment.

How Passkey Authentication Works

Both sign-up and sign-in follow the same three-step pattern:

  1. The server generates a random challenge and sends it to the browser along with some options.
  2. The browser asks the device to verify the user and sign the challenge.
  3. The browser sends the signed response back, and the server validates it.

During sign-up (WebAuthn refers to this as "registration"), the device creates a new key pair and returns the public key. The server then validates the response and stores the public key.

During sign-in (authentication), the device signs the new challenge with the existing private key. The server then validates the signature using the public key it had previously saved.

Two more concepts come up throughout the code:

The relying party ID (RP ID) is the domain your passkeys belong to, such as example.com. In development, it's localhost. The browser will simply refuse to use a passkey on any other domain, which is what gives passkeys their resistance to phishing.

The user verification (UV) flag is a bit within the signed response. It's only set by the device when the user has actually completed the fingerprint, face, or screen lock check. Your server looks at this flag to ensure that it wasn't just some random user who tapped a key or clicked a button.

Project Structure

The project has two separate apps in one repository:

passwordless-auth/
├── client/   # Next.js frontend, runs on port 3000
└── server/   # NestJS API, runs on port 4000

You'll build the server first, since the frontend depends on its endpoints.

How to Set Up the NestJS Backend

Start by creating a new NestJS project with the Nest CLI:

npx @nestjs/cli new server
cd server

Then install the dependencies for WebAuthn, the database, configuration, and JSON Web Tokens (JWTs):

npm install @simplewebauthn/server @nestjs/typeorm typeorm pg @nestjs/config @nestjs/jwt @nestjs/passport passport passport-jwt

Create the Database

Create an empty database called auth. From the terminal, run:

createdb -h localhost -U postgres auth

If you prefer pgAdmin, right-click Databases, choose Create, then Database, and name it auth.

If you skip this step, NestJS will fail on startup with error: database "auth" does not exist. TypeORM can create tables for you, but it won't create the database itself.

Add Environment Variables

Create a .env file in the server folder:

JWT_SECRET=replace-with-a-long-random-string

WEBAUTHN_RP_ID=localhost
WEBAUTHN_ORIGIN=http://localhost:3000

DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=your-database-password
DB_NAME=auth

WEBAUTHN_RP_ID is the domain only, without a protocol or port. WEBAUTHN_ORIGIN is the exact URL of your frontend. The server will reject any response that comes from a different origin.

Browsers only allow WebAuthn on HTTPS sites or on localhost, so this setup works in development without certificates.

Connect to PostgreSQL

Create src/auth/config/database.config.ts to group the database settings:

import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT, 10),
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  entities: [__dirname + '/../**/*.entity{.ts,.js}'],
  synchronize: process.env.NODE_ENV !== 'production',
}));

While you're in the config folder, add src/auth/config/jwt.config.ts for the token settings:

import { registerAs } from '@nestjs/config';

export default registerAs('jwt', () => ({
  secret: process.env.JWT_SECRET,
  expiresIn: '1h',
}));

The synchronize option tells TypeORM to create and update tables based on your entities. It's convenient in development, but you should switch to migrations before going to production.

Then wire everything up in src/app.module.ts:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import databaseConfig from './auth/config/database.config';
import jwtConfig from './auth/config/jwt.config';
import { User, Authenticator } from './auth/entities/auth.entity';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [databaseConfig, jwtConfig],
    }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get('database.host'),
        port: configService.get('database.port'),
        username: configService.get('database.username'),
        password: configService.get('database.password'),
        database: configService.get('database.database'),
        entities: [User, Authenticator],
        synchronize: configService.get('database.synchronize'),
      }),
      inject: [ConfigService],
    }),
    AuthModule,
  ],
})
export class AppModule {}

Finally, enable CORS in src/main.ts so the frontend on port 3000 can call the API on port 4000:

import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  app.enableCors({
    origin: true,
    credentials: true,
    allowedHeaders: ['Content-Type', 'Authorization'],
  });
  await app.listen(4000);
  console.log('App running on port 4000');
}
bootstrap();

origin: true accepts requests from any origin, which keeps local development simple. Before you deploy, replace it with your frontend's URL.

How to Define the Database Entities

You need two tables here. The User table contains the account, and the Authenticator table contains the passkeys. One user can have multiple passkeys (for example, one on their laptop and one on their phone).

Create src/auth/entities/auth.entity.ts:

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  ManyToOne,
  OneToMany,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';
import type {
  CredentialDeviceType,
  Base64URLString,
} from '@simplewebauthn/server';

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column({ unique: true })
  webauthnId: Base64URLString;

  @Column({ type: 'varchar', nullable: true })
  currentChallenge?: string | null;

  @OneToMany(() => Authenticator, (authenticator) => authenticator.user)
  authenticators: Authenticator[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

@Entity()
export class Authenticator {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  credentialId: Base64URLString;

  @Column({ type: 'bytea' })
  credentialPublicKey: Uint8Array;

  @ManyToOne(() => User, (user) => user.authenticators, { onDelete: 'CASCADE' })
  user: User;

  @Column()
  webauthnUserId: Base64URLString;

  @Column({ type: 'bigint' })
  counter: number;

  @Column({
    type: 'varchar',
    length: 32,
  })
  deviceType: CredentialDeviceType;

  @Column()
  backedUp: boolean;

  @Column('simple-array', { nullable: true })
  transports?: string[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

Let's take a closer look at a couple of these fields.

First, webauthnId is the user handle that gets stored on the device together with the passkey. The WebAuthn spec says it shouldn't contain personal information, so you'll fill it with random bytes instead of the email.

Next, currentChallenge holds the challenge the server is waiting for. It's set when you generate options and cleared after a successful verification. Notice the string | null type. That matters, and you'll see why in the security section.

counter is a signature counter. Some authenticators will increment it on every use, which allows the server to detect a cloned device. Synced passkeys will typically leave the counter at zero, and SimpleWebAuthn supports both scenarios.

deviceType and backedUp tell you whether the passkey can sync across devices and whether it's currently backed up (for example, to iCloud Keychain).

And finally, transports records how the browser can get to the authenticator (built into the device, over USB, over Bluetooth, or via a phone).

How to Register a Passkey on the Server

Create src/auth/auth.service.ts. Start with the class setup:

import {
  Injectable,
  UnauthorizedException,
  ConflictException,
  Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomBytes } from 'crypto';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User, Authenticator } from './entities/auth.entity';
import {
  generateRegistrationOptions,
  generateAuthenticationOptions,
  verifyRegistrationResponse,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
  AuthenticationResponseJSON,
  PublicKeyCredentialCreationOptionsJSON,
  PublicKeyCredentialRequestOptionsJSON,
  RegistrationResponseJSON,
} from '@simplewebauthn/server';

@Injectable()
export class AuthService {
  private readonly logger = new Logger(AuthService.name);
  private readonly rpName = 'Passwordless Auth';
  private readonly rpID: string;
  private readonly origin: string;

  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectRepository(Authenticator)
    private readonly authenticatorRepository: Repository<Authenticator>,
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
  ) {
    this.rpID = this.configService.get<string>('WEBAUTHN_RP_ID');
    this.origin = this.configService.get<string>('WEBAUTHN_ORIGIN');
  }

rpName is the human-readable name of your app. Users will see it in the operating system's passkey prompt and later in their password manager.

Generate Registration Options

The first method finds or creates the user, then asks SimpleWebAuthn for registration options. Add it inside the class:

async generateRegistrationOptions(
    email: string,
): Promise<PublicKeyCredentialCreationOptionsJSON> {
  let user = await this.userRepository.findOne({ where: { email } });

  if (!user) {
    user = this.userRepository.create({
      email,
      webauthnId: randomBytes(32).toString('base64url'),
    });
    await this.userRepository.save(user);
  }

  const authenticators = await this.authenticatorRepository.find({
    where: { user: { id: user.id } },
  });

  if (authenticators.length > 0) {
    throw new ConflictException(
      'An account with this email already exists. Please sign in.',
    );
  }

  const options = await generateRegistrationOptions({
    rpName: this.rpName,
    rpID: this.rpID,
    userID: Buffer.from(user.webauthnId, 'base64url'),
    userName: user.email,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'required',
    },
    excludeCredentials: authenticators.map((authenticator) => ({
      id: authenticator.credentialId,
      transports: authenticator.transports,
    })),
  });

  // Save the current challenge
  user.currentChallenge = options.challenge;
  await this.userRepository.save(user);

  return options;
}

The authenticatorSelection block is where you decide what kind of passkey you want.

residentKey: 'required' asks for a discoverable credential. That's what makes it a true passkey (the credential is stored on the device or in the user's password manager and can then sync to their other devices).

userVerification: 'required' tells the device that it needs to verify the user (that is, check their fingerprint, face, or screen lock). Without that, some of the authenticators would just do a presence check and not actually verify the user.

What's missing is an authenticator attachment option. If you set it to 'platform', that would effectively lock users into using the sensor on their current device. If you leave it out, then they can use their phone via QR code or a hardware security key (which is what passkeys.dev recommends).

attestationType: 'none' means you're not asking the device to attest to its make and model. Most consumer apps don't need that at all, and asking for it can in fact trigger additional privacy prompts.

excludeCredentials lists the passkeys the user already has so the device won't create a duplicate on an authenticator that's already registered. Given the check immediately above it, that list is always empty in this case. It becomes useful later on when you allow signed-in users to add further passkeys.

The check for existing authenticators is also important. This endpoint doesn't require the user to be signed in, so you can't just allow it to add a passkey to an account that already has one. Otherwise, anyone who knows your email address could attach their own passkey to your account.

Verify the Registration Response

After the user confirms on their device, the browser sends back a response. The second method verifies it and stores the new passkey:

async verifyRegistration(email: string, response: RegistrationResponseJSON) {
  const user = await this.userRepository.findOne({
    where: { email },
    select: ['id', 'email', 'currentChallenge', 'webauthnId'],
  });

  if (!user) {
    throw new UnauthorizedException('User not found');
  }

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: user.currentChallenge,
    expectedOrigin: this.origin,
    expectedRPID: this.rpID,
    requireUserVerification: true,
  });

  const { verified, registrationInfo } = verification;

  if (verified && registrationInfo) {
    const { credential, credentialDeviceType, credentialBackedUp } =
      registrationInfo;

    const newAuthenticator = this.authenticatorRepository.create({
      credentialId: credential.id,
      credentialPublicKey: credential.publicKey,
      counter: credential.counter,
      user: user,
      webauthnUserId: user.webauthnId,
      deviceType: credentialDeviceType,
      backedUp: credentialBackedUp,
      transports: response.response.transports,
    });

    await this.authenticatorRepository.save(newAuthenticator);

    user.currentChallenge = null;
    await this.userRepository.save(user);

    return {
      verified,
      user: { id: user.id, email: user.email },
    };
  }

  throw new UnauthorizedException('Registration verification failed');
}

verifyRegistrationResponse is what does the hard work in this code. It checks that the challenge matches the one you saved, that the response came from your origin and RP ID, and that the signature is valid. With requireUserVerification: true it'll also reject the response if the UV flag isn't set. That flag is your proof that a fingerprint, face, or screen lock check took place.

If all goes well, you'll save the credential ID and the public key and then clear the challenge so it can't be reused.

How to Sign In with a Passkey on the Server

Sign-in works the same way: generate options, then verify the response.

Generate Authentication Options

async generateAuthenticationOptions(
    email: string,
): Promise<PublicKeyCredentialRequestOptionsJSON> {
  const user = await this.userRepository.findOne({ where: { email } });

  if (!user) {
    throw new UnauthorizedException('User not found');
  }

  const authenticators = await this.authenticatorRepository.find({
    where: { user: { id: user.id } },
  });

  if (authenticators.length === 0) {
    throw new UnauthorizedException(
      'No passkey registered for this account. Please sign up.',
    );
  }

  const options = await generateAuthenticationOptions({
    rpID: this.rpID,
    allowCredentials: authenticators.map((authenticator) => ({
      id: authenticator.credentialId,
      transports: authenticator.transports,
    })),
    userVerification: 'required',
  });

  // Save the current challenge
  user.currentChallenge = options.challenge;
  await this.userRepository.save(user);

  return options;
}

allowCredentials lists the passkeys this user owns. The browser uses that list to find the right one on the device, and the transports hint helps it decide whether to look locally, on a security key, or on a phone.

Verify the Authentication Response

async verifyAuthentication(
  email: string,
  response: AuthenticationResponseJSON,
) {
  const user = await this.userRepository.findOne({
    where: { email },
    select: ['id', 'email', 'currentChallenge'],
  });

  if (!user) {
    throw new UnauthorizedException('User not found');
  }

  const authenticator = await this.authenticatorRepository.findOne({
    where: { credentialId: response.id, user: { id: user.id } },
  });

  if (!authenticator) {
    throw new UnauthorizedException('Authenticator not found');
  }

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: user.currentChallenge,
    expectedOrigin: this.origin,
    expectedRPID: this.rpID,
    credential: {
      id: authenticator.credentialId,
      publicKey: new Uint8Array(authenticator.credentialPublicKey),
      counter: Number(authenticator.counter),
      transports: authenticator.transports,
    },
    requireUserVerification: true,
  });

  const { verified } = verification;

  if (verified) {
    authenticator.counter = verification.authenticationInfo.newCounter;
    authenticator.backedUp =
      verification.authenticationInfo.credentialBackedUp;
    await this.authenticatorRepository.save(authenticator);

    user.currentChallenge = null;
    await this.userRepository.save(user);

    // Generate JWT token
    const payload = { sub: user.id, email: user.email };
    return {
      accessToken: this.jwtService.sign(payload),
      user: { id: user.id, email: user.email },
    };
  }

  throw new UnauthorizedException('Authentication verification failed');
}

The lookup uses both the credential ID and the user ID. Don't drop the user condition. I'll explain why in the security section.

The Number() call around the counter is there because the pg driver returns bigint columns as strings. SimpleWebAuthn expects a number.

Once the signature has been verified, you increment the counter, clear the challenge, and issue a JWT. From that point on, the app treats the user exactly as it would any user who has signed in with a password. The only difference is that a password wasn't used.

How to Expose the Auth Endpoints

Create src/auth/auth.controller.ts. It has one route for each step of the sign-up and sign-in flows, plus a protected route that lists a user's passkeys:

import { Controller, Post, Body, Get, Query, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('register/options')
  async getRegistrationOptions(
    @Body() body: { email: string },
  ): Promise<PublicKeyCredentialCreationOptionsJSON> {
    return this.authService.generateRegistrationOptions(body.email);
  }

  @Post('register/verify')
  async verifyRegistration(
    @Body() body: { email: string; response: any },
  ) {
    return this.authService.verifyRegistration(body.email, body.response);
  }

  @Post('login/options')
  async getAuthenticationOptions(
    @Body() body: { email: string },
  ): Promise<PublicKeyCredentialRequestOptionsJSON> {
    return this.authService.generateAuthenticationOptions(body.email);
  }

  @Post('login/verify')
  async verifyAuthentication(
    @Body() body: { email: string; response: any },
  ) {
    return this.authService.verifyAuthentication(body.email, body.response);
  }

  @Get('authenticators')
  @UseGuards(JwtAuthGuard)
  async getAuthenticators(@Query('email') email: string) {
    return this.authService.getAuthenticators(email);
  }
}

The response body is typed as any here because the service methods already declare the exact SimpleWebAuthn types they expect.

How to Protect Routes with a JWT

The last route usesJwtAuthGuard, so only signed-in users can call it. It's backed by a small service method. Add it to AuthService:

async getAuthenticators(email: string) {
  const user = await this.userRepository.findOne({ where: { email } });
  if (!user) {
    throw new UnauthorizedException('User not found');
  }
  const authenticators = await this.authenticatorRepository.find({
    where: { user: { id: user.id } },
  });
  return authenticators;
}

Next, create the Passport strategy that reads and validates the token in src/auth/strategies/jwt.strategy.ts:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, email: payload.email };
  }
}

Then create the guard in src/auth/guards/jwt-auth.guard.ts. It wraps Passport's built-in guard and returns clearer error messages:

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor() {
    super();
  }

  handleRequest(err: any, user: any, info: any) {
    if (info instanceof TokenExpiredError) {
      throw new UnauthorizedException('Token has expired');
    }

    if (info instanceof JsonWebTokenError) {
      throw new UnauthorizedException('Invalid token');
    }

    if (err || !user) {
      throw new UnauthorizedException('Authentication failed');
    }

    return user;
  }

  // Optional: Override canActivate if you need custom logic before JWT validation
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();

    const authHeader = request.headers.authorization;
    if (!authHeader) {
      throw new UnauthorizedException('No token provided');
    }

    const [type] = authHeader.split(' ');
    if (type !== 'Bearer') {
      throw new UnauthorizedException('Invalid token format');
    }
    return super.canActivate(context) as Promise<boolean>;
  }
}

How to Register the Auth Module

Finally, bring everything together in src/auth/auth.module.ts:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User, Authenticator } from './entities/auth.entity';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  imports: [
    TypeOrmModule.forFeature([User, Authenticator]),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get('JWT_SECRET'),
        signOptions: {
          expiresIn: '1d',
        },
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}

The signOptions here control the tokens the service issues, so each sign-in is valid for one day.

Start the server:

npm run start:dev

TypeORM will create the user and authenticator tables in your auth database. You can open pgAdmin and refresh the Tables node to see them.

How to Set Up the Next.js Frontend

From the root of the project, create a Next.js app with TypeScript, Tailwind CSS, the App Router, and a src folder:

npx create-next-app@latest client --typescript --tailwind --app --src-dir
cd client
npm install @simplewebauthn/browser axios

Add a .env file in the client folder that points to the API:

NEXT_PUBLIC_API_URL=http://localhost:4000

Then create a small API client in client/src/services/api.ts:

/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from 'axios';

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

export const webAuthnApi = {
  getRegistrationOptions: async (email: string) => {
    const response = await api.post('/auth/register/options', { email });
    return response.data;
  },

  verifyRegistration: async (email: string, response: any) => {
    const res = await api.post('/auth/register/verify', { email, response });
    return res.data;
  },

  getAuthenticationOptions: async (email: string) => {
    const response = await api.post('/auth/login/options', { email });
    return response.data;
  },

  verifyAuthentication: async (email: string, response: any) => {
    const res = await api.post('/auth/login/verify', { email, response });
    return res.data;
  },
};

// Add request interceptor to include JWT token
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export default api;

The interceptor at the bottom attaches the JWT to every request once the user has signed in, which is what the protected /auth/authenticators route expects.

How to Build the Sign Up Page

The frontend side of WebAuthn is surprisingly short. SimpleWebAuthn's startRegistration function takes the options from your server, calls the browser's WebAuthn API, and returns a response in a JSON format your server understands.

Create client/src/app/signup/page.tsx:

/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';

import React, { useState } from 'react';
import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser';
import { webAuthnApi } from '@/services/api';
import { getPasskeyErrorMessage } from '@/services/passkey';
import Link from 'next/link';
import { useRouter } from 'next/navigation';

export default function SignupPage() {
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const router = useRouter();

  const handleRegistration = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    if (!browserSupportsWebAuthn()) {
      setError('This browser does not support passkeys.');
      setLoading(false);
      return;
    }

    try {
      const options = await webAuthnApi.getRegistrationOptions(email);

      const regResult = await startRegistration({ optionsJSON: options });

      const verificationResult = await webAuthnApi.verifyRegistration(email, regResult);

      if (verificationResult.verified) {
        router.push('/welcome');
      }
    } catch (err: any) {
      console.error('Registration error:', err);
      setError(getPasskeyErrorMessage(err, 'Registration failed. Please try again.'));
    } finally {
      setLoading(false);
    }
  };

  return (
    <main className="min-h-screen flex items-center justify-center bg-gray-300">
      <div className="bg-white p-8 rounded-lg shadow-sm w-full max-w-md">
        <div className="space-y-6">
          <div className="text-center">
            <h1 className="text-3xl font-bold tracking-tight">Sign up</h1>
            <p className="text-sm text-gray-600 mt-2">Create your account</p>
          </div>

          <form onSubmit={handleRegistration} className="space-y-4">
            <div className="space-y-2">
              <label htmlFor="email" className="block text-sm font-medium text-gray-700">
                Email
              </label>
              <input
                id="email"
                type="email"
                required
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                placeholder="Type your email"
              />
            </div>

            {error && (
              <div className="text-sm text-red-600">
                {error}
              </div>
            )}

            <button
              type="submit"
              disabled={loading}
              className="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#3B97B6] hover:bg-[#2d7591] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
            >
              {loading ? 'Creating passkey...' : 'Create a passkey'}
            </button>
            <p className="text-xs text-center text-gray-500">
              You'll confirm with your fingerprint, face, or screen lock.
            </p>
          </form>

          <div className="text-center text-sm">
            <span className="text-gray-600">Already have an account?</span>{' '}
            <Link
              href="/signin"
              className="text-[#3B97B6] hover:text-[#2d7591] font-medium transition-colors"
            >
              Sign in
            </Link>
          </div>
        </div>
      </div>
    </main>
  );
}

The handleRegistration function follows the three steps from earlier. You fetch options from the server, hand them tostartRegistration, and send the result back for verification.

The call to startRegistration is where the magic happens. The browser opens the operating system's passkey dialog, and the user confirms with Touch ID, Face ID, Windows Hello, or their phone. Your code just waits for the promise to resolve.

Note the { optionsJSON: options } argument. Older versions of SimpleWebAuthn accepted the options directly, and many tutorials still show it that way. Version 13 still supports the old style but logs a warning, so use the object form.

Sign up page with an email field and a "Create a passkey" button

How to Build the Sign In Page

The Sign in page mirrors the Sign up page. Create client/src/app/signin/page.tsx, copy the sign up page into it, and change the headings and the link at the bottom so they point to /signup. Then import startAuthentication instead of startRegistration, and replace the submit handler with this one:

const handleAuthentication = async (e: React.FormEvent) => {
  e.preventDefault();
  setLoading(true);
  setError('');

  if (!browserSupportsWebAuthn()) {
    setError('This browser does not support passkeys.');
    setLoading(false);
    return;
  }

  try {
    const options = await webAuthnApi.getAuthenticationOptions(email);

    const authResult = await startAuthentication({ optionsJSON: options });

    const verificationResult = await webAuthnApi.verifyAuthentication(email, authResult);

    if (verificationResult.accessToken) {
      localStorage.setItem('token', verificationResult.accessToken);
      router.push('/welcome');
    }
  } catch (err: any) {
    console.error('Authentication error:', err);
    setError(getPasskeyErrorMessage(err, 'Authentication failed. Please try again.'));
  } finally {
    setLoading(false);
  }
};

Remember to pass handleAuthentication to the form's onSubmit. You can see the finished page in the GitHub repository.

When verification succeeds, the server returns a JWT, and the page stores it before redirecting. Storing tokens in localStorage keeps this demo simple. In a production app, a httpOnly cookie set by the server is a safer choice because JavaScript running on the page can't read it.

Operating system passkey prompt asking the user to confirm with Touch ID

How to Show Helpful Error Messages

When something goes wrong during a passkey ceremony, the browser throws a DOMException with a name that tells you what happened. The raw messages aren't very friendly, so it's a good idea to translate the common ones.

Create client/src/services/passkey.ts:

/* eslint-disable @typescript-eslint/no-explicit-any */

// Turn errors from the browser's WebAuthn API and our backend into readable messages
export function getPasskeyErrorMessage(err: any, fallback: string): string {
  if (err?.response?.data?.message) {
    return err.response.data.message;
  }
  switch (err?.name) {
    case 'NotAllowedError':
      // User cancelled the fingerprint/face/screen-lock prompt, or it timed out
      return 'The passkey prompt was cancelled or timed out. Please try again.';
    case 'InvalidStateError':
      // A passkey for this account already exists on this authenticator
      return 'This device already has a passkey for this account. Please sign in instead.';
    case 'NotSupportedError':
      return 'This device does not support passkeys.';
    case 'SecurityError':
      return 'Passkeys are not allowed on this domain. Check the WebAuthn RP ID and origin settings.';
    default:
      return fallback;
  }
}

NotAllowedError is the one you'll see most. It fires when the user closes the prompt, waits too long, or fails the biometric check. SecurityError usually means your RP ID doesn't match the domain the page is running on, which is a configuration problem rather than a user problem.

How to Test Your App

Start both apps in separate terminals:

# Terminal 1
cd server
npm run start:dev
# Terminal 2
cd client
npm run dev

Go to http://localhost:3000/signup, enter your email, and click the button. Your device will show the prompt about the passkey. On a Mac with Touch ID, you'll be asked to touch the sensor. Otherwise, you'll need to fill the form with your MacBook user password. On Windows, Windows Hello will ask for your face, fingerprint, or PIN. If you choose to use another device, the browser shows a QR code that you can scan with your phone.

After you confirm, you land on the welcome page. Now go to /signin, enter the same email, and confirm again. This time the server returns a JWT.

If you don't have a passkey-enabled device available, Chrome has a built-in virtual authenticator. Open DevTools, click the three-dot menu, then go to More tools and choose WebAuthn. Enable the virtual authenticator environment and add an authenticator with "Supports user verification" checked. Chrome will then simulate a passkey device for you.

You can also open pgAdmin and look at the authenticator table. You'll see the credential ID, the public key stored as bytes, and the device type. There's no password and nothing secret in there.

Security Mistakes to Avoid

Passkeys are secure by design, but the server code around them still has to be right. I ran into each of the issues below while building this project. Some of them don't throw any errors, which is what makes them dangerous.

Encoding the Credential ID Twice

In recent versions of SimpleWebAuthn, including version 14 used here, credential.id is already a base64url string. My first version of the code did this:

// Wrong: encodes an already-encoded string
credentialId: Buffer.from(credential.id).toString('base64url'),

Buffer.from treats the string as UTF-8 text, so the result is a base64url encoding of a base64url string. Registration succeeds, but sign-in can never find the credential because the browser sends the original ID. Store credential.id as it is.

Clearing the Challenge with undefined

Every challenge should be used only once. This line looks like it clears it:

user.currentChallenge = undefined;

But TypeORM skips undefined properties when it saves an entity, so the old challenge stays in the database. Someone who captured a valid response could replay it. Set the value to null instead, and make sure the column type allows it.

Looking Up a Credential Without Checking Its Owner

It's tempting to find the authenticator by credential ID alone:

// Wrong: any user's passkey matches
where: { credentialId: response.id }

But here's the problem: the attacker asks for sign-in options for your email, signs your challenge with their own passkey, and the server sees their passkey, validates the signature against their public key, and then issues a token for your account. Always include the user in the lookup as per the earlier example.

Letting Sign Up Add Passkeys to Existing Accounts

The registration endpoint is public. If it's going to add a passkey to an account that matches the email, then anyone who knows your email can register their own passkey and sign in as you.

Don't allow registration if the account already has a passkey. If you want people to be able to add more passkeys, then that can be a separate feature which requires them to be signed in.

Asking for Verification Without Requiring It

If you request userVerification: 'preferred', the device is allowed to skip the fingerprint or PIN check. If you then verify with requireUserVerification: true, which is SimpleWebAuthn's default, users with those devices will get confusing failures. Pick 'required' on both sides so the options and the verification agree.

Where to Go From Here

The app you've built covers the fundamental passkey flows. But there are a couple of enhancements you could make if you wanted to go further.

Passkey autofill (also known as conditional UI) will cause the browser to suggest the saved passkey directly in the email field, just as it will suggest a saved password. SimpleWebAuthn supports this via the useBrowserAutofill option of startAuthentication.

Users will also want to add passkeys for their other devices and remove old ones. You'd build that as a pair of endpoints behind your JWT guard.

Finally, challenges should expire. A simple way to do this is to store a timestamp alongside currentChallenge and then reject anything older than a few minutes. In a larger app, you'd probably store the challenges in a session or a cache (for example, Redis) rather than on the user record.

Conclusion

In this tutorial, you've built a complete passwordless authentication system using passkeys. You've got a full NestJS API that can generate WebAuthn challenges, verify the signed responses using SimpleWebAuthn, and store the public keys in PostgreSQL. You've also got a Next.js frontend that allows users to sign up and sign in using their fingerprint, their face, or their device's screen lock.

You've now learned how the challenge and signature flow works, why the user verification flag is important, and why the server can't decide which biometric method the user will see. You've also seen a number of rather subtle bugs that can result in a sign-in failure or even an account takeover, and how to avoid them.

Passwords aren't going away any time soon, but passkeys are already supported on pretty much every device that your users have. You now know how to add them to your own apps.

The full source code is available on GitHub. Go ahead and clone it, break it, and make it your own. If you found this tutorial useful in any way, then please do pass it on to someone who’s about to have the same question.



Read the whole story
alvinashcraft
13 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Bobcat 1.0 is here!

1 Share

JasperFx released Bobcat 1.0 yesterday, our new MIT license tool for:

  • “Executable Specifications”, but this time we’re focused strictly on being able to “project” human readable specifications for easy review with domain experts or us developers from the testing tools we already use, including xUnit.net, TUnit, MSTest, and NUnit. No Gherkin, markdown, or wiki text that gets magically mapped to code. Just C# or F#.
  • Okay, a little bit of markdown tables within the tests to for declarative table-centric testing that I’ve always found very valuable — but at least this time the source generators in Bobcat can verify the syntax at compile time!
  • Improved test automation with the kind of infrastructure heavy integration tests that are rife with flakiness and long testing times through its Supervisor library. See Introducing the Bobcat Supervisor for Reliable .NET Integration Suites for more about this.
  • Spec Driven Development within the Critter Stack’s overarching Event Modeling strategy, and some of the Critter Stack’s new application scaffolding from Event Models landed in Bobcat just because that’s what deals with specifications

For the dozen of you out there who remember my previous Storyteller tool, Bobcat is the spiritual successor that tries to incorporate the elements of Storyteller that were very effective (especially for data intensive testing where Gherkin isn’t a great fit in my opinion), but ditch the elements of Storyteller that made it clumsy to use and to ultimately fail.

And of course, this is the era of AI assisted development so there absolutely has to be an AI hook! One of the lessons that Bobcat took from Storyteller was the ability to embed and report on application telemetry and instrumentation as part of reporting test failures for easier test failure debugging — and folks, there will be test failures and bigger tests involving asynchronous behavior can be nasty to debug. As an example, the Bobcat integration package for Wolverine message handlers “knows” how to write telemetry about all the messaging behavior including failures, retries, dead letter queues, scheduled messages, outgoing messages, and exceptions that happened during the execution to console output in a way that’s optimized for AI agent consumption. The hope here is that this makes it easy for AI agents to quickly cycle between test failures and fixes by giving the AI agents exactly the information they need to complete their tasks.

We have also updated the JasperFx AI Skills pack for Bobcat usage, including skills to help trouble shoot specifications, to run specifications in “watch” mode for faster cycles, and to utilize the supervisor functionality for more robust and optimized CI builds.

Lastly, and this is a little weird for me to consider, but Bobcat is going to be the very last OSS project that I ever started before the advent of AI assisted development. There wasn’t much there, but Bobcat at its core does use some of the earliest code I wrote when I was just piddling around with this in odd moments and the original vision for Bobcat comes from over a decade of me thinking and talking to others about what a Storyteller replacement could look like.

Anyway, check it out and let us know about any feedback you have on the Critter Stack Discord server. There’s already a bobcat room open.

Why is it called Bobcat?

Because I think that bobcats are cool? I picked the codename years ago before we’d adopted the current “Critter Stack” Mustilidae naming scheme, but it took awhile to get around to making it real.



Read the whole story
alvinashcraft
45 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

8 examples of Gemini 4 Argon in action

1 Share
From deep research to interactive design, here are eight examples of what Gemini 4 Argon, our most intelligent model yet, is capable of.
Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

We’ve had touchscreen laptops for years. Can Apple make them better?

1 Share
Apple MacBook Pro 16 2023
Seeing the light on touchscreen laptops. | Photo by Amelia Holowaty Krales / The Verge

A fridge is about to be merged with a toaster. Dogs and cats are soon to be living together. But it's not mass hysteria; Apple is expected to finally announce a touchscreen MacBook tomorrow at its "Pro and Tell" event.

A laptop with a touchscreen should be no big deal - we've had them for ages. But Apple took a hardline stance against them. In addition to Tim Cook's infamous 2012 quote equating it to combining kitchen appliances, Steve Jobs emphatically dismissed touchscreen laptops as "ergonomically terrible" back in 2010, saying, "Touch surfaces don't want to be vertical."

The closest Apple got was the MacBook Pro Touch Bar, from 2016 t …

Read the full story at The Verge.

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

Fitbit Edge: Premium design for an active life

1 Share
With a reimagined screen, refined mechanics, and elevated style, Fitbit Edge looks as good as it performs, year after year.
Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

The platform approach to becoming Frontier

1 Share

Last year at Ignite, we introduced the concept of Frontier Transformation and how it is different from any technology-led pursuit, because it requires us to focus on business outcomes first. It starts with a simple idea: technology was invented to serve humanity, not the other way around. There is no path where this notion of infinite cognitive compute replaces human purpose. Frontier Transformation is an opportunity for a wholesale reinvention of how you think about your unique ideas coming to life and how your organization can thrive in your industry.

Over the course of this past year, we have been leading our customers to focus on the three durable traits that are required for successful transformation: human ambition, intelligence and trust. We are taking a platform approach toward enabling the same: Copilot as the place to call home for interacting with AI to empower human ambition; the Microsoft IQ platform to compound and amplify your intelligence from within while protecting your unique differentiation; and Agent 365 to enable Trust and Observability at every layer of the stack so that you can govern, manage, secure and get business value and return out of the capabilities you deliver.

Our latest earnings are another important marker on that journey. Behind the numbers are thousands of customers, partners, developers and employees putting AI to work in meaningful ways. We have so many inspiring stories from our customers that highlight these outcomes, and below are some of our favorites.

Quote from Judson Althoff, CEO of Microsoft Commercial Business: “Technology was invented to serve humanity, not the other way around. There’s no path where somehow this notion of infinite cognitive compute replaces human purpose.”

Empowering human ambition with Copilot

Volkswagen has scaled Microsoft Copilot to more than 330,000 employees as it expands the role of AI in decision-making and knowledge-based processes across the enterprise. Building on Copilot Chat adoption that reached more than 300,000 monthly active users, employees are now using Copilot with Work IQ for more advanced scenarios, bringing greater organizational context to decision-making and knowledge-based work. Volkswagen is also working with Microsoft to further scale its Intelligent Data Foundation on Microsoft Fabric to make data easier to find, understand and use across the entire company. With this investment, Volkswagen is demonstrating how companies have evolved from using Copilot as a productivity tool to an operating system to empower human ambition.

Following a successful pilot, Fujitsu is scaling Microsoft Copilot across its global workforce of approximately 100,000. Copilot usage has surpassed 90%, and the company is using the tool to connect employees with specialized agents and business systems. AI agents are already automating roughly 75% of initial responses and triage for a service desk that receives nearly 300,000 inquiries annually, while new agents are supporting work across sales, systems engineering, procurement, finance and accounting. As customer zero, Fujitsu is using Copilot as the front door to a multi-agent work environment and using insights from its own AI adoption to better serve its customers.

KOHLER is using GitHub Copilot to transform software development, with one organization improving code releases by 25% and reducing error defects by 75%. These engineering breakthroughs are part of a broader effort to embed AI processes across the company, spanning IT, finance, marketing, supply chain and operations. Microsoft Copilot has reached 98% adoption, saving an average of 2 to 5 hours per month as employees are making informed decisions and getting answers faster in the flow of their work. Employees across the company have also created more than 3,000 agents to date, with hundreds active daily.

At Krafton, Microsoft Security Copilot and Microsoft Defender XDR are helping the security team respond to a growing volume and complexity of threats. The global gaming company automated key parts of security operations, from alerting and investigation to reporting, combining Microsoft Security solutions with its own systems and data to quickly gather context that helps distinguish real threats from false positives. This approach is having immediate results: security alert recognition time has fallen by approximately 95% to less than one minute, investigation work that previously took tens of minutes now takes about three minutes and roughly 95% of report creation has been automated. By taking on repetitive work, AI is giving Krafton’s security professionals more time to focus on responding to real threats and making critical security decisions.

To accelerate discovery across decades of scientific data, NASA is making complex information easier to explore through Microsoft Copilot. NASA employees are using Copilot to sift through massive amounts of academic literature and streamline daily tasks, and specialized assistants are helping researchers navigate complex Earth science data through natural language, extending access to information that once required significant technical expertise. Hydrology Copilot, for example, can analyze variables like precipitation and soil moisture to help communities better understand water conditions, prepare for droughts and anticipate flood risks, while automatically performing calculations and analyses.

To transform frontline work, Williams, a US natural gas company, is using Microsoft Copilot to help employees spend less time searching documents and completing manual processes, and more time on critical field operations. Copilot helps teams generate plans, interpret procedures, assess regulatory requirements, troubleshoot issues and complete safety reporting, while employees can create agents tailored to their own workflows. Demonstrating that the person closest to a problem is often the best one to solve it, one operations technician built an anomaly detector using Copilot that compares equipment data against baseline statistics and alerts teams to potential issues before they become costly or dangerous. Since deploying Copilot, AI adoption at Williams has grown from single digits to more than 90%, helping employees accelerate operations, improve reporting and compliance and identify equipment issues earlier.

Bristol Myers Squibb has rolled out Microsoft Copilot to 25,000 employees, reaching 96% adoption, and is now putting AI agents to work across everyday use cases such as document review and project management in support of the company’s mission to bring more medicines to more patients faster. One agent, called RABIT, analyzes meeting transcripts, surfaces action items and delivers insights directly within employees’ workflows.

Facing rising patient demand and financial pressures, Brown University Health is using Dragon Copilot to give clinicians more time to focus on patients. The health system adopted Dragon Copilot, and an early pilot across 420 clinicians found users spent less time on after-hours documentation and reported lower cognitive burden. Building on that experience, Brown University Health deployed Microsoft Copilot and Copilot Studio across departments, which led to the creation of more than 24 AI agents to support clinical workflows, hospital policies, patient access and operations. These AI solutions are not only supporting clinicians in the flow of their work but also helping to improve the patient experience and access to care.

Microsoft IQ compounds and protects your intelligence

BNY has made AI part of how its employees work through Eliza, its in-house AI platform built with Microsoft Azure, Microsoft Foundry, Microsoft Copilot and GitHub Copilot – with plans to add Work IQ. More than 300 solutions now run on Eliza, ranging from inquiry management to client onboarding, and 100% of employees have been trained on the platform. Across the bank, that scale is translating into meaningful results: client transaction inquiries are being resolved 80% faster and AI-assisted client onboarding is 20% faster on average. Through the Microsoft CEO Co-Innovation program, BNY also worked with Microsoft to develop an AI research capability with Foundry that investigates complex data issues, analyzes context across multiple sources, identifies likely root causes and recommends next steps, bringing AI-assisted reasoning into processes that have traditionally relied on human investigation alone.

Docusign is helping 1.9 million customers across 180 countries turn agreement data into actionable business insights. The company built an Intelligent Agreement Management platform on Microsoft Azure and used Microsoft Foundry to shift from general-purpose models to a system of task-specific models that better understand context. This change allows Docusign to process more than a million agreement documents daily with up to eightfold greater throughput and 90% lower AI processing costs. Foundry also supports a model lifecycle designed for continuous improvement, where processes are fine-tuned before deployment and monitored in production, while also providing the governance and traceability needed to meet enterprise requirements for compliance, visibility and control. Now agreement content is structured data that compounds from within, so organizations can use it to make decisions and automate workflows, knowing their IP is protected.

To help marketers create more effective content, Kantar turned decades of proprietary advertising intelligence into action with Microsoft Azure and Microsoft Foundry. Working with Microsoft Frontier Company, Kantar connected its LINK AI platform to its proprietary intelligence, AI models and creative workflows, enabling marketers to evaluate, optimize and generate content before it goes to market. The new approach can process and score large volumes of videos 20x faster while reducing platform operating costs by 4x, helping customers move from insight to action in minutes rather than hours. By bringing its unique intelligence into a continuous loop of observation and improvement, Kantar is creating new ways to help marketers turn insight into better creative and business outcomes.

Kinaxis, a Canadian supply chain software company, runs Maestro, its AI-driven supply chain orchestration platform, on Microsoft Azure. Maestro helps businesses make better decisions faster amid constant disruption. It brings together traditionally disparate business data like sales orders, inventory and production capacity with external signals like weather, market shifts and breaking news to give planners a near real-time view of their supply chains. With this unified data foundation, planners and analysts can use predictive AI and optimization models to anticipate demand shifts, evaluate constraints and recommend action, while generative AI agents act as assistants to ask questions, explore scenarios and automate parts of the planning process. With this intelligence in one platform, businesses can understand how disruptions ripple across their interconnected operations, weigh trade-offs and adjust plans as conditions change.

Moody’s is making more than a century of credit and risk intelligence directly accessible through Microsoft Copilot, giving customers trusted, protected context for complex decisions. Through a federated connector built using Model Context Protocol, customers can access live, decision-grade Moody’s intelligence through Researcher in Copilot and within Word, Excel and Teams, without copying or exporting the underlying data. A single question can synthesize information across credit ratings, company financials, ownership information and physical risk models, helping customers reach insights in minutes instead of spending hours or days on research and analysis. This brings trusted, connected intelligence directly into workflows where customers make complex decisions.

To turn conversations into actionable intelligence at global scale, Plaud, an AI hardware and software company, is using Microsoft Azure OpenAI in Foundry Models and Azure Speech in Foundry Tools to power transcription, summarization and AI inference across long-form audio and multilingual scenarios. Serving more than 2 million users worldwide, Plaud increased transcription accuracy and processing speed by 35% and improved inference performance by 30%, helping professionals turn spoken content into structured outputs and insights. Even as its user base doubled, the company used Foundry’s elastic scaling and flexible deployment models to reduce overall compute costs by 35%, while strengthening its ability to meet security, privacy and compliance requirements across global markets.

Rockwell Automation built a Maintenance Copilot using Microsoft Foundry to put decades of manufacturing expertise in the hands of employees. The AI assistant brings together expert knowledge from veteran engineers, manufacturing data and machine manuals, allowing technicians to quickly identify likely causes of equipment issues, see how colleagues have solved similar problems and access step-by-step troubleshooting guidance. At its Singapore factory, the approach has helped reduce machine downtime by 33% and cut servicing and spare-part costs by about 25%. Rockwell estimates it has also reduced the time it takes new workers to learn to troubleshoot hundreds of machines from nine months to three months.

With 1.5 billion passengers riding its elevators and escalators every day, TK Elevator knows that every minute of downtime affects how people move through airports, hospitals and offices. Building on a decade of connected-equipment data on Microsoft Azure, the company created AI agents with Microsoft Foundry that brief service technicians before they enter a building, telling them which elevator, which floor, what the issue is and which tools to bring. After each job, a voice agent records the technician’s account of the work in their own words, with no typing, and adds that expertise to a shared record. With 25,000 technicians caring for 1.4 million units worldwide, TK Elevator is turning each technician’s know-how into compounded intelligence every colleague can draw on.

Telstra partnered with Microsoft Frontier Company to deploy an AI-powered Migration Payment Agent that helps simplify one of the most complex aspects of customer migration: reconciling billing and payments across legacy and new platforms. The agent uses intelligence across these systems to proactively identify inconsistencies, recommend corrective actions and create enriched cases for human review, combining AI-driven insights with human-in-the-loop validation to maintain operational trust and governance. Already supporting more than 272,000 service migrations and delivering recommendations at 95%+ accuracy in production, the solution demonstrates how Telstra is applying agentic AI at enterprise scale to transform complex operations, improve customer outcomes and build reusable AI capabilities while compounding and protecting Telstra’s operational intelligence.

Agent 365 observes AI solutions across the stack

To advance its transformation into an AI-powered organization, Manulife is adopting Microsoft 365 E7: The Frontier Suite, expanding Microsoft Copilot to more than 30,000 employees and leveraging Microsoft Agent 365 to govern, manage and secure AI agents at enterprise scale. With Microsoft Azure and Microsoft Foundry supporting its global enterprise AI platform, Manulife is building a foundation for teams to develop and deploy AI and agentic solutions while maintaining standards for quality, security and cost efficiency. AI is already delivering impact across the business, from generative AI solutions supporting more than 110 million customer calls annually to developers increasing productivity by 30% with assisted and autonomous AI capabilities. With agentic solutions and AI-powered workflows already in production, Manulife is creating the foundation to scale AI responsibly across its global operations and accelerate its next phase of enterprise transformation.

Insight Enterprises, a leading solutions integrator and top Microsoft partner, is using Microsoft Agent 365 to govern AI agents at scale. As a Microsoft customer zero organization for Agent 365, Insight Enterprises is quick to adopt new capabilities internally and pass those learnings on to clients. With Microsoft engineering, it tested how Microsoft and third-party agents could be observed, managed, governed and secured through a unified control plane. To date, Insight Enterprises has discovered 9,734 agents with visibility into which are active, what data they can access and how to monitor their activity over time. The experience has helped Insight Enterprises build practical AI governance expertise it can bring to customers, including organizations in highly regulated industries.

KPMG is adopting Microsoft Agent 365 to manage and secure AI agents across its global organization, providing centralized observability and control as it scales agentic AI. Agent 365 will also strengthen KPMG Workbench, its multi-agent platform built on Microsoft Foundry that coordinates agents across client service delivery platforms. At the same time, KPMG is expanding Microsoft Copilot to more than 276,000 professionals worldwide, helping embed AI more deeply across audit, tax and advisory services. Together, these capabilities are giving KPMG the confidence and security to move agents from experimentation into enterprise-wide deployment, while helping clients do the same.

Operating in one of the most highly regulated industries, where protecting sensitive customer information is essential, Truist Financial Corporation is using Agent 365 to manage, govern and observe more than 13,000 AI declarative agents across its organization. Truist manages its agent ecosystem with discipline: every agent has an accountable owner and agents are automatically disabled after 45 days of inactivity. That rigor is driving strong retention and adoption, with more than 8,000 agents active each month. Building on this trusted foundation, Truist has advanced its broader AI strategy through their investment of more than 50,000 Microsoft 365 E7: The Frontier Suite seats.

—

Every quarter gives us an opportunity to step back and look at the progress we are making with our customers and partners around the world. What continues to strike me is not simply the pace of change, but the ambition we see from organizations using technology to achieve aspirations that simply weren’t possible before. We look forward to working alongside you on the path to becoming Frontier, and we hope to see many of you at Microsoft Ignite this year in November to share the latest innovations that will enable you to reach that destination.

Judson Althoff is the chief executive officer of the commercial business at Microsoft. He is responsible for the product strategy, sales, services, support, marketing, operations and revenue growth of the company’s commercial business, which operates in more than 120 regional and national subsidiaries globally.

The post The platform approach to becoming Frontier appeared first on The Official Microsoft Blog.

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories