New Device Notifications
You’ve probably received an email like this before:

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.
How It Works
Section titled “How It Works”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:
- Initialize
fetchWithDeviceonce when your app loads - Use
fetchWithDeviceinstead of regularfetchfor authenticated requests - Check the
newDeviceDetectedflag 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.
Getting Started
Section titled “Getting Started”-
[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-Agentconst 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-Agentresponse = 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-AgentCreateDeviceChallengeResponse 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-Agentvar 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" });}} -
[Frontend] Install BYO Library
Section titled “[Frontend] Install BYO Library”PropelAuth BYO comes with a JavaScript library to make adding device registration simple.
Terminal window npm install @propelauth/byo-javascript -
[Frontend] Initialize BYO Library
Section titled “[Frontend] Initialize BYO Library”Initialize the library using the
initFetchWithDevicefunction. 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 laterreturn null;},fallbackBehavior: "fail",});Note that if you are using an SSR framework like Next.js or Remix, you should only call
initFetchWithDeviceon the client side. You can do this by checking ifwindowis defined:if (typeof window !== "undefined") {initFetchWithDevice({// ...});}And then import this file in your root component or layout so it runs when your app loads.
-
[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
fetchWithDevicefunction. 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 }),}); -
[Backend] Add Device Registration to Session Creation
Section titled “[Backend] Add Device Registration to Session Creation”fetchWithDevicewill automatically include a new headerdpopin 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 successelse: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 varyUser 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 successreturn 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 varyvar 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 successreturn Ok();}catch (CreateSessionException){return StatusCode(500, new { Error = "Failed to create session" });}}You have now detected if the device has not been previously registered!
-
[Both] Handle Device Challenge Error
Section titled “[Both] Handle Device Challenge Error”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
NewDeviceChallengeRequirederror 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 successelif 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
getChallengeFromFailedResponsefunction 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,
fetchWithDevicewill take the new challenge, sign it, and retry the request. -
[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
fetchWithDeviceinstead offetch. See Session Theft Protection for more details. - Pass
ignore_device_for_verificationin 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.
- Update all your authenticated requests to use
Additional Considerations
Section titled “Additional Considerations”What about logins that don’t use fetch?
Section titled “What about logins that don’t use fetch?”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.