Skip to content

New Device Notifications

You’ve probably received an email like this before:

email of a new device notification

New device notifications alert users when someone logs in from an unrecognized device. This helps users spot unauthorized access immediately and take action if needed.

Device registration uses cryptographic keys to securely identify each device. When a user logs in from a new device, PropelAuth BYO automatically detects it and returns a newDeviceDetected flag. You can then notify the user about the new device login.

We provide a frontend library with a function fetchWithDevice that is a drop-in replacement for fetch, except it automatically handles device registration for you.

The process is simple:

  1. Initialize fetchWithDevice once when your app loads
  2. Use fetchWithDevice instead of regular fetch for authenticated requests
  3. Check the newDeviceDetected flag when creating sessions

PropelAuth BYO handles all the cryptographic complexity behind the scenes - generating keys, managing challenges, and verifying signatures.

Why Cryptographic Keys Over Fingerprinting?

Section titled “Why Cryptographic Keys Over Fingerprinting?”

Browser fingerprinting collects device attributes (user agent, screen resolution, installed fonts, etc.) to create a unique identifier. While it can work, it has significant drawbacks:

  • Privacy concerns - Users and regulations like GDPR increasingly view fingerprinting as invasive
  • False positives - Browser updates or privacy tools can change fingerprints unexpectedly
  • Limited security - Fingerprints can be spoofed or copied

Our cryptographic approach is privacy-friendly, reliable, and provides stronger security. Plus, the same keys enable session theft protection for even better security.

  1. [Backend] Create a Device Challenge endpoint

    Section titled “[Backend] Create a Device Challenge endpoint”

    Your frontend will need to fetch device challenges in order to prove its identity. Create a backend endpoint that uses the Create Device Challenge Command to generate a challenge.

    app.get("/api/device-challenge", async (req: Request, res: Response) => {
    // Recommended: tie the challenge to the user's IP and User-Agent
    const response = await auth.session.device.createChallenge({
    ipAddress: req.socket.remoteAddress,
    userAgent: req.headers["user-agent"],
    });
    if (response.ok) {
    res.status(200).json({
    deviceChallenge: response.data.deviceChallenge,
    expiresAt: response.data.expiresAt,
    });
    } else {
    console.error("Error creating device challenge:", response.error);
    return res.status(500).json({ error: "Failed to create device challenge" });
    }
    )
    @app.get("/api/device-challenge")
    async def device_challenge_endpoint(request: Request):
    # Recommended: tie the challenge to the user's IP and User-Agent
    response = await client.session.device.create_challenge(
    ip_address=request.client.host,
    user_agent=request.headers.get("user-agent")
    )
    if is_ok(response):
    return {
    "deviceChallenge": response.data.device_challenge,
    "expiresAt": response.data.expires_at
    }
    else:
    print("Error creating device challenge:", response.error)
    raise HTTPException(status_code=500, detail="Failed to create device challenge")
    @GetMapping("/api/device-challenge")
    public ResponseEntity<?> deviceChallengeEndpoint(HttpServletRequest request) {
    try {
    // Recommended: tie the challenge to the user's IP and User-Agent
    CreateDeviceChallengeResponse response = client.session.device.createChallenge(
    CreateDeviceChallengeCommand.builder()
    .ipAddress(request.getRemoteAddr())
    .userAgent(request.getHeader("User-Agent"))
    .build()
    );
    return ResponseEntity.ok(Map.of(
    "deviceChallenge", response.getDeviceChallenge(),
    "expiresAt", response.getExpiresAt()
    ));
    } catch (CreateDeviceChallengeException e) {
    System.err.println("Error creating device challenge: " + e);
    return ResponseEntity.status(500).body(Map.of("error", "Failed to create device challenge"));
    }
    }
    [HttpGet("/api/device-challenge")]
    public async Task<IActionResult> DeviceChallengeEndpoint()
    {
    try
    {
    // Recommended: tie the challenge to the user's IP and User-Agent
    var response = await client.Session.Device.CreateChallengeAsync(new CreateDeviceChallengeCommand
    {
    IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
    UserAgent = Request.Headers["User-Agent"].ToString()
    });
    return Ok(new
    {
    DeviceChallenge = response.DeviceChallenge,
    ExpiresAt = response.ExpiresAt
    });
    }
    catch (CreateDeviceChallengeException e)
    {
    Console.Error.WriteLine($"Error creating device challenge: {e}");
    return StatusCode(500, new { Error = "Failed to create device challenge" });
    }
    }
  2. PropelAuth BYO comes with a JavaScript library to make adding device registration simple.

    Terminal window
    npm install @propelauth/byo-javascript
  3. Initialize the library using the initFetchWithDevice function. You’ll include three options:

    • fetchChallenge: A function that fetches a device challenge from your backend.
    • getChallengeFromFailedResponse: A function that extracts a device challenge from a failed response (we’ll add this later).
    • fallbackBehavior: What to do if the browser doesn’t support the required features. You can choose to either “fail” (throw an error) or “fallback” (continue without device registration).
    import { initFetchWithDevice } from "@propelauth/byo-javascript";
    initFetchWithDevice({
    fetchChallenge: async () => {
    const response = await fetch("/api/device-challenge");
    const jsonResponse = await response.json();
    return {
    deviceChallenge: jsonResponse.deviceChallenge,
    expiresAt: new Date(jsonResponse.expiresAt * 1000),
    };
    },
    getChallengeFromFailedResponse: async (response) => {
    // TODO: we'll add this later
    return null;
    },
    fallbackBehavior: "fail",
    });

    Note that if you are using an SSR framework like Next.js or Remix, you should only call initFetchWithDevice on the client side. You can do this by checking if window is defined:

    if (typeof window !== "undefined") {
    initFetchWithDevice({
    // ...
    });
    }

    And then import this file in your root component or layout so it runs when your app loads.

  4. [Frontend] Update Fetch to Include Device Challenge

    Section titled “[Frontend] Update Fetch to Include Device Challenge”

    Update your login fetch request(s) to use the fetchWithDevice function. This will automatically include the required headers for device registration in the request.

    import { fetchWithDevice } from "@propelauth/byo-javascript";
    const response = await fetchWithDevice("/api/login", {
    method: "POST",
    body: JSON.stringify({ username, password }),
    });
  5. [Backend] Add Device Registration to Session Creation

    Section titled “[Backend] Add Device Registration to Session Creation”

    fetchWithDevice will automatically include a new header dpop in the request. You’ll want to grab that and add it to your Create Session call. This will register the device and return if the device has not been used previously by the user.

    const getSignedDeviceChallenge = (req: Request): string | undefined => {
    const dpopHeader = req.headers["dpop"];
    if (!dpopHeader || typeof dpopHeader !== "string") {
    return undefined;
    }
    return dpopHeader;
    };
    app.post("/api/login", async (req: Request, res: Response) => {
    const signedDeviceChallenge = getSignedDeviceChallenge(req);
    // This example treats device registration as required.
    // You could make it optional by only passing deviceRegistration when signedDeviceChallenge exists.
    if (!signedDeviceChallenge) {
    return res.status(401).json({ error: "Device challenge required" });
    }
    const result = await client.session.create({
    userId: user.id,
    ipAddress: req.socket.remoteAddress,
    userAgent: req.headers["user-agent"],
    deviceRegistration: {
    signedDeviceChallenge,
    rememberDevice: true,
    },
    });
    if (result.ok) {
    if (result.data.newDeviceDetected) {
    // send new device notification email
    }
    // Save session token in cookie and return success
    } else {
    res.status(500).json({ error: "Failed to create session" });
    }
    });
    from propelauth_byo.generated.device_registration import DeviceRegistration
    @app.post("/api/login")
    async def login(request: Request):
    signed_device_challenge = request.headers.get("dpop")
    # This example treats device registration as required.
    # You could make it optional by only passing device_registration when signed_device_challenge exists.
    if not signed_device_challenge:
    raise HTTPException(status_code=401, detail="Device challenge required")
    result = await client.session.create(
    user_id=user.id,
    ip_address=request.client.host,
    user_agent=request.headers.get("user-agent"),
    device_registration=DeviceRegistration(
    signed_device_challenge=signed_device_challenge,
    remember_device=True
    )
    )
    if is_ok(result):
    if result.data.new_device_detected:
    # send new device notification email
    # Save session token in cookie and return success
    else:
    raise HTTPException(status_code=500, detail="Failed to create session")
    @PostMapping("/api/login")
    public ResponseEntity<?> login(HttpServletRequest request) {
    // Validate credentials - this is your code and may vary
    User user = validateLogin(request);
    String signedDeviceChallenge = request.getHeader("dpop");
    // This example treats device registration as required.
    // You could make it optional by only passing deviceRegistration when signedDeviceChallenge exists.
    if (signedDeviceChallenge == null) {
    return ResponseEntity.status(401).body(Map.of("error", "Device challenge required"));
    }
    try {
    CreateSessionResponse result = client.session.create(
    CreateSessionCommand.builder()
    .userId(user.getId())
    .ipAddress(request.getRemoteAddr())
    .userAgent(request.getHeader("User-Agent"))
    .deviceRegistration(DeviceRegistration.builder()
    .signedDeviceChallenge(signedDeviceChallenge)
    .rememberDevice(true)
    .build())
    .build()
    );
    if (result.getNewDeviceDetected()) {
    // send new device notification email
    }
    // Save session token in cookie and return success
    return ResponseEntity.ok().build();
    } catch (CreateSessionException e) {
    return ResponseEntity.status(500).body(Map.of("error", "Failed to create session"));
    }
    }
    [HttpPost("/api/login")]
    public async Task<IActionResult> Login()
    {
    // Validate credentials - this is your code and may vary
    var user = ValidateLogin(Request);
    var signedDeviceChallenge = Request.Headers["dpop"].ToString();
    // This example treats device registration as required.
    // You could make it optional by only passing DeviceRegistration when signedDeviceChallenge exists.
    if (string.IsNullOrEmpty(signedDeviceChallenge))
    {
    return Unauthorized(new { Error = "Device challenge required" });
    }
    try
    {
    var result = await client.Session.CreateAsync(new CreateSessionCommand
    {
    UserId = user.Id,
    IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
    UserAgent = Request.Headers["User-Agent"].ToString(),
    DeviceRegistration = new DeviceRegistration
    {
    SignedDeviceChallenge = signedDeviceChallenge,
    RememberDevice = true
    }
    });
    if (result.NewDeviceDetected == true)
    {
    // send new device notification email
    }
    // Save session token in cookie and return success
    return Ok();
    }
    catch (CreateSessionException)
    {
    return StatusCode(500, new { Error = "Failed to create session" });
    }
    }

    You have now detected if the device has not been previously registered!

  6. While rare, it’s possible that the device challenge could be invalid when you go to log the user in. This could happen, for example, if the user’s IP address changed between fetching the challenge and logging in.

    To account for this, you can check for the NewDeviceChallengeRequired error type, which will include a new device challenge that you can return to the frontend:

    if (result.ok) {
    if (result.data.newDeviceDetected) {
    // send new device notification email
    }
    // Save session token in cookie and return success
    } else if (result.error.type === "NewDeviceChallengeRequired") {
    res.status(425).json({
    errorType: "NewDeviceChallengeRequired",
    deviceChallenge: result.error.details.deviceChallenge,
    expiresAt: result.error.details.expiresAt,
    });
    } else {
    res.status(500).json({ error: "Failed to create session" });
    }
    if is_ok(result):
    if result.data.new_device_detected:
    # send new device notification email
    # Save session token in cookie and return success
    elif result.error.type == "NewDeviceChallengeRequired":
    return JSONResponse(
    status_code=425,
    content={
    "errorType": "NewDeviceChallengeRequired",
    "deviceChallenge": result.error.details.device_challenge,
    "expiresAt": result.error.details.expires_at
    }
    )
    else:
    raise HTTPException(status_code=500, detail="Failed to create session")
    try {
    RegisterDeviceResponse result = client.session.device.register(...);
    if (result.getNewDeviceDetected()) {
    // send new device notification email
    }
    // Save session token in cookie and return success
    } catch (RegisterDeviceException.NewDeviceChallengeRequired e) {
    return ResponseEntity.status(425).body(Map.of(
    "errorType", "NewDeviceChallengeRequired",
    "deviceChallenge", e.getDetails().getDeviceChallenge(),
    "expiresAt", e.getDetails().getExpiresAt()
    ));
    } catch (RegisterDeviceException e) {
    return ResponseEntity.status(500).body(Map.of("error", "Failed to create session"));
    }
    try
    {
    var result = await client.Session.Device.RegisterAsync(...);
    if (result.NewDeviceDetected)
    {
    // send new device notification email
    }
    // Save session token in cookie and return success
    }
    catch (RegisterDeviceException.NewDeviceChallengeRequired e)
    {
    return StatusCode(425, new
    {
    ErrorType = "NewDeviceChallengeRequired",
    DeviceChallenge = e.Details.DeviceChallenge,
    ExpiresAt = e.Details.ExpiresAt
    });
    }
    catch (RegisterDeviceException)
    {
    return StatusCode(500, new { Error = "Failed to create session" });
    }

    Then we can go back to the frontend and update our getChallengeFromFailedResponse function to extract the challenge from this error response:

    getChallengeFromFailedResponse: async (response) => {
    if (response.status === 425) {
    const jsonResponse = await response.json();
    if (jsonResponse.errorType === "NewDeviceChallengeRequired") {
    return {
    deviceChallenge: jsonResponse.deviceChallenge,
    expiresAt: new Date(jsonResponse.expiresAt * 1000),
    };
    }
    }
    return null;
    },

    Now, if the device challenge happens to be invalid, fetchWithDevice will take the new challenge, sign it, and retry the request.

  7. [Backend] Add Session Theft Protection or Skip It

    Section titled “[Backend] Add Session Theft Protection or Skip It”

    Now that you’ve added device registration to your login flow, BYO will expect all Validate Session calls to include device registration proof. This means you have two options:

    • Update all your authenticated requests to use fetchWithDevice instead of fetch. See Session Theft Protection for more details.
    • Pass ignore_device_for_verification in to Validate Session to skip device verification for existing sessions. This is useful if you want to add device registration gradually without breaking existing sessions.

Some login flows may not use fetch, such as OAuth redirects / SSO. In these cases, you can create the session without device registration, and add it later using the Register Device function.

When you validate a session, you’ll get back an indicator of whether or not it has a device registered. If not, you can call the Register Device function to register the device at that time and detect if it’s new.