diff --git a/i18n/en.json b/i18n/en.json
index b712faa3c2..05b236b33a 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1,4 +1,19 @@
 {
+  "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
+  "enter_your_pin_code": "Enter your PIN code",
+  "enter_your_pin_code_subtitle": "Enter your PIN code to access the locked folder",
+  "pin_verification": "PIN code verification",
+  "wrong_pin_code": "Wrong PIN code",
+  "nothing_here_yet": "Nothing here yet",
+  "move_to_locked_folder": "Move to Locked Folder",
+  "remove_from_locked_folder": "Remove from Locked Folder",
+  "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the Locked Folder",
+  "remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of Locked Folder? They will be visible in your library",
+  "move": "Move",
+  "no_locked_photos_message": "Photos and videos in Locked Folder are hidden and won't show up as you browser your library.",
+  "locked_folder": "Locked Folder",
+  "add_to_locked_folder": "Add to Locked Folder",
+  "move_off_locked_folder": "Move out of Locked Folder",
   "user_pin_code_settings": "PIN Code",
   "user_pin_code_settings_description": "Manage your PIN code",
   "current_pin_code": "Current PIN code",
@@ -837,6 +852,7 @@
   "error_saving_image": "Error: {error}",
   "error_title": "Error - Something went wrong",
   "errors": {
+    "unable_to_move_to_locked_folder": "Unable to move to locked folder",
     "cannot_navigate_next_asset": "Cannot navigate to the next asset",
     "cannot_navigate_previous_asset": "Cannot navigate to previous asset",
     "cant_apply_changes": "Can't apply changes",
diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart
index 708aec603f..d054749b1e 100644
--- a/mobile/lib/utils/openapi_patching.dart
+++ b/mobile/lib/utils/openapi_patching.dart
@@ -29,6 +29,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
     case 'UserResponseDto':
       if (value is Map) {
         addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
+        addDefault(value, 'visibility', AssetVisibility.timeline);
       }
       break;
     case 'UserAdminResponseDto':
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 9a3055911d..3aed98adf1 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -117,6 +117,7 @@ Class | Method | HTTP request | Description
 *AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code | 
 *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | 
 *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | 
+*AuthenticationApi* | [**verifyPinCode**](doc//AuthenticationApi.md#verifypincode) | **POST** /auth/pin-code/verify | 
 *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | 
 *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | 
 *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | 
diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart
index f850bdf403..446a0616ed 100644
--- a/mobile/openapi/lib/api/authentication_api.dart
+++ b/mobile/openapi/lib/api/authentication_api.dart
@@ -396,4 +396,43 @@ class AuthenticationApi {
     }
     return null;
   }
+
+  /// Performs an HTTP 'POST /auth/pin-code/verify' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [PinCodeSetupDto] pinCodeSetupDto (required):
+  Future<Response> verifyPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/auth/pin-code/verify';
+
+    // ignore: prefer_final_locals
+    Object? postBody = pinCodeSetupDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [PinCodeSetupDto] pinCodeSetupDto (required):
+  Future<void> verifyPinCode(PinCodeSetupDto pinCodeSetupDto,) async {
+    final response = await verifyPinCodeWithHttpInfo(pinCodeSetupDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
 }
diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart
index 5f01f84419..74af8bd1eb 100644
--- a/mobile/openapi/lib/model/asset_response_dto.dart
+++ b/mobile/openapi/lib/model/asset_response_dto.dart
@@ -43,6 +43,7 @@ class AssetResponseDto {
     required this.type,
     this.unassignedFaces = const [],
     required this.updatedAt,
+    required this.visibility,
   });
 
   /// base64 encoded sha1 hash
@@ -132,6 +133,8 @@ class AssetResponseDto {
 
   DateTime updatedAt;
 
+  AssetResponseDtoVisibilityEnum visibility;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
     other.checksum == checksum &&
@@ -163,7 +166,8 @@ class AssetResponseDto {
     other.thumbhash == thumbhash &&
     other.type == type &&
     _deepEquality.equals(other.unassignedFaces, unassignedFaces) &&
-    other.updatedAt == updatedAt;
+    other.updatedAt == updatedAt &&
+    other.visibility == visibility;
 
   @override
   int get hashCode =>
@@ -197,10 +201,11 @@ class AssetResponseDto {
     (thumbhash == null ? 0 : thumbhash!.hashCode) +
     (type.hashCode) +
     (unassignedFaces.hashCode) +
-    (updatedAt.hashCode);
+    (updatedAt.hashCode) +
+    (visibility.hashCode);
 
   @override
-  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]';
+  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -270,6 +275,7 @@ class AssetResponseDto {
       json[r'type'] = this.type;
       json[r'unassignedFaces'] = this.unassignedFaces;
       json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
+      json[r'visibility'] = this.visibility;
     return json;
   }
 
@@ -312,6 +318,7 @@ class AssetResponseDto {
         type: AssetTypeEnum.fromJson(json[r'type'])!,
         unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
         updatedAt: mapDateTime(json, r'updatedAt', r'')!,
+        visibility: AssetResponseDtoVisibilityEnum.fromJson(json[r'visibility'])!,
       );
     }
     return null;
@@ -378,6 +385,87 @@ class AssetResponseDto {
     'thumbhash',
     'type',
     'updatedAt',
+    'visibility',
   };
 }
 
+
+class AssetResponseDtoVisibilityEnum {
+  /// Instantiate a new enum with the provided [value].
+  const AssetResponseDtoVisibilityEnum._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const archive = AssetResponseDtoVisibilityEnum._(r'archive');
+  static const timeline = AssetResponseDtoVisibilityEnum._(r'timeline');
+  static const hidden = AssetResponseDtoVisibilityEnum._(r'hidden');
+  static const locked = AssetResponseDtoVisibilityEnum._(r'locked');
+
+  /// List of all possible values in this [enum][AssetResponseDtoVisibilityEnum].
+  static const values = <AssetResponseDtoVisibilityEnum>[
+    archive,
+    timeline,
+    hidden,
+    locked,
+  ];
+
+  static AssetResponseDtoVisibilityEnum? fromJson(dynamic value) => AssetResponseDtoVisibilityEnumTypeTransformer().decode(value);
+
+  static List<AssetResponseDtoVisibilityEnum> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetResponseDtoVisibilityEnum>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AssetResponseDtoVisibilityEnum.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [AssetResponseDtoVisibilityEnum] to String,
+/// and [decode] dynamic data back to [AssetResponseDtoVisibilityEnum].
+class AssetResponseDtoVisibilityEnumTypeTransformer {
+  factory AssetResponseDtoVisibilityEnumTypeTransformer() => _instance ??= const AssetResponseDtoVisibilityEnumTypeTransformer._();
+
+  const AssetResponseDtoVisibilityEnumTypeTransformer._();
+
+  String encode(AssetResponseDtoVisibilityEnum data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a AssetResponseDtoVisibilityEnum.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  AssetResponseDtoVisibilityEnum? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'archive': return AssetResponseDtoVisibilityEnum.archive;
+        case r'timeline': return AssetResponseDtoVisibilityEnum.timeline;
+        case r'hidden': return AssetResponseDtoVisibilityEnum.hidden;
+        case r'locked': return AssetResponseDtoVisibilityEnum.locked;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [AssetResponseDtoVisibilityEnumTypeTransformer] instance.
+  static AssetResponseDtoVisibilityEnumTypeTransformer? _instance;
+}
+
+
diff --git a/mobile/openapi/lib/model/asset_visibility.dart b/mobile/openapi/lib/model/asset_visibility.dart
index 4d0c7ee8d3..498bf17c38 100644
--- a/mobile/openapi/lib/model/asset_visibility.dart
+++ b/mobile/openapi/lib/model/asset_visibility.dart
@@ -26,12 +26,14 @@ class AssetVisibility {
   static const archive = AssetVisibility._(r'archive');
   static const timeline = AssetVisibility._(r'timeline');
   static const hidden = AssetVisibility._(r'hidden');
+  static const locked = AssetVisibility._(r'locked');
 
   /// List of all possible values in this [enum][AssetVisibility].
   static const values = <AssetVisibility>[
     archive,
     timeline,
     hidden,
+    locked,
   ];
 
   static AssetVisibility? fromJson(dynamic value) => AssetVisibilityTypeTransformer().decode(value);
@@ -73,6 +75,7 @@ class AssetVisibilityTypeTransformer {
         case r'archive': return AssetVisibility.archive;
         case r'timeline': return AssetVisibility.timeline;
         case r'hidden': return AssetVisibility.hidden;
+        case r'locked': return AssetVisibility.locked;
         default:
           if (!allowNull) {
             throw ArgumentError('Unknown enum value to decode: $data');
diff --git a/mobile/openapi/lib/model/auth_status_response_dto.dart b/mobile/openapi/lib/model/auth_status_response_dto.dart
index 203923164f..0ccd87114e 100644
--- a/mobile/openapi/lib/model/auth_status_response_dto.dart
+++ b/mobile/openapi/lib/model/auth_status_response_dto.dart
@@ -13,30 +13,36 @@ part of openapi.api;
 class AuthStatusResponseDto {
   /// Returns a new [AuthStatusResponseDto] instance.
   AuthStatusResponseDto({
+    required this.isElevated,
     required this.password,
     required this.pinCode,
   });
 
+  bool isElevated;
+
   bool password;
 
   bool pinCode;
 
   @override
   bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto &&
+    other.isElevated == isElevated &&
     other.password == password &&
     other.pinCode == pinCode;
 
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
+    (isElevated.hashCode) +
     (password.hashCode) +
     (pinCode.hashCode);
 
   @override
-  String toString() => 'AuthStatusResponseDto[password=$password, pinCode=$pinCode]';
+  String toString() => 'AuthStatusResponseDto[isElevated=$isElevated, password=$password, pinCode=$pinCode]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
+      json[r'isElevated'] = this.isElevated;
       json[r'password'] = this.password;
       json[r'pinCode'] = this.pinCode;
     return json;
@@ -51,6 +57,7 @@ class AuthStatusResponseDto {
       final json = value.cast<String, dynamic>();
 
       return AuthStatusResponseDto(
+        isElevated: mapValueOfType<bool>(json, r'isElevated')!,
         password: mapValueOfType<bool>(json, r'password')!,
         pinCode: mapValueOfType<bool>(json, r'pinCode')!,
       );
@@ -100,6 +107,7 @@ class AuthStatusResponseDto {
 
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
+    'isElevated',
     'password',
     'pinCode',
   };
diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart
index e1d3199428..f5d59b6ae9 100644
--- a/mobile/openapi/lib/model/sync_asset_v1.dart
+++ b/mobile/openapi/lib/model/sync_asset_v1.dart
@@ -293,12 +293,14 @@ class SyncAssetV1VisibilityEnum {
   static const archive = SyncAssetV1VisibilityEnum._(r'archive');
   static const timeline = SyncAssetV1VisibilityEnum._(r'timeline');
   static const hidden = SyncAssetV1VisibilityEnum._(r'hidden');
+  static const locked = SyncAssetV1VisibilityEnum._(r'locked');
 
   /// List of all possible values in this [enum][SyncAssetV1VisibilityEnum].
   static const values = <SyncAssetV1VisibilityEnum>[
     archive,
     timeline,
     hidden,
+    locked,
   ];
 
   static SyncAssetV1VisibilityEnum? fromJson(dynamic value) => SyncAssetV1VisibilityEnumTypeTransformer().decode(value);
@@ -340,6 +342,7 @@ class SyncAssetV1VisibilityEnumTypeTransformer {
         case r'archive': return SyncAssetV1VisibilityEnum.archive;
         case r'timeline': return SyncAssetV1VisibilityEnum.timeline;
         case r'hidden': return SyncAssetV1VisibilityEnum.hidden;
+        case r'locked': return SyncAssetV1VisibilityEnum.locked;
         default:
           if (!allowNull) {
             throw ArgumentError('Unknown enum value to decode: $data');
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 3c0dc09953..2dbec35079 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -2470,6 +2470,41 @@
         ]
       }
     },
+    "/auth/pin-code/verify": {
+      "post": {
+        "operationId": "verifyPinCode",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/PinCodeSetupDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Authentication"
+        ]
+      }
+    },
     "/auth/status": {
       "get": {
         "operationId": "getAuthStatus",
@@ -9150,6 +9185,15 @@
           "updatedAt": {
             "format": "date-time",
             "type": "string"
+          },
+          "visibility": {
+            "enum": [
+              "archive",
+              "timeline",
+              "hidden",
+              "locked"
+            ],
+            "type": "string"
           }
         },
         "required": [
@@ -9171,7 +9215,8 @@
           "ownerId",
           "thumbhash",
           "type",
-          "updatedAt"
+          "updatedAt",
+          "visibility"
         ],
         "type": "object"
       },
@@ -9226,7 +9271,8 @@
         "enum": [
           "archive",
           "timeline",
-          "hidden"
+          "hidden",
+          "locked"
         ],
         "type": "string"
       },
@@ -9241,6 +9287,9 @@
       },
       "AuthStatusResponseDto": {
         "properties": {
+          "isElevated": {
+            "type": "boolean"
+          },
           "password": {
             "type": "boolean"
           },
@@ -9249,6 +9298,7 @@
           }
         },
         "required": [
+          "isElevated",
           "password",
           "pinCode"
         ],
@@ -12664,7 +12714,8 @@
             "enum": [
               "archive",
               "timeline",
-              "hidden"
+              "hidden",
+              "locked"
             ],
             "type": "string"
           }
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index 144e7f8ac1..ad7413e6fd 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -329,6 +329,7 @@ export type AssetResponseDto = {
     "type": AssetTypeEnum;
     unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
     updatedAt: string;
+    visibility: Visibility;
 };
 export type AlbumResponseDto = {
     albumName: string;
@@ -520,6 +521,7 @@ export type PinCodeSetupDto = {
     pinCode: string;
 };
 export type AuthStatusResponseDto = {
+    isElevated: boolean;
     password: boolean;
     pinCode: boolean;
 };
@@ -2076,6 +2078,15 @@ export function changePinCode({ pinCodeChangeDto }: {
         body: pinCodeChangeDto
     })));
 }
+export function verifyPinCode({ pinCodeSetupDto }: {
+    pinCodeSetupDto: PinCodeSetupDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchText("/auth/pin-code/verify", oazapfts.json({
+        ...opts,
+        method: "POST",
+        body: pinCodeSetupDto
+    })));
+}
 export function getAuthStatus(opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
@@ -3574,7 +3585,8 @@ export enum UserStatus {
 export enum AssetVisibility {
     Archive = "archive",
     Timeline = "timeline",
-    Hidden = "hidden"
+    Hidden = "hidden",
+    Locked = "locked"
 }
 export enum AlbumUserRole {
     Editor = "editor",
@@ -3591,6 +3603,12 @@ export enum AssetTypeEnum {
     Audio = "AUDIO",
     Other = "OTHER"
 }
+export enum Visibility {
+    Archive = "archive",
+    Timeline = "timeline",
+    Hidden = "hidden",
+    Locked = "locked"
+}
 export enum AssetOrder {
     Asc = "asc",
     Desc = "desc"
diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts
index 56acaa5c6d..5d3ba8be95 100644
--- a/server/src/controllers/auth.controller.ts
+++ b/server/src/controllers/auth.controller.ts
@@ -101,4 +101,11 @@ export class AuthController {
   async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
     return this.service.resetPinCode(auth, dto);
   }
+
+  @Post('pin-code/verify')
+  @HttpCode(HttpStatus.OK)
+  @Authenticated()
+  async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
+    return this.service.verifyPinCode(auth, dto);
+  }
 }
diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts
index 14130fabcb..39d2cb8fcd 100644
--- a/server/src/controllers/search.controller.spec.ts
+++ b/server/src/controllers/search.controller.spec.ts
@@ -66,7 +66,7 @@ describe(SearchController.name, () => {
         .send({ visibility: 'immich' });
       expect(status).toBe(400);
       expect(body).toEqual(
-        errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden']),
+        errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden, locked']),
       );
     });
 
diff --git a/server/src/database.ts b/server/src/database.ts
index a13b074448..29c746aa1f 100644
--- a/server/src/database.ts
+++ b/server/src/database.ts
@@ -200,6 +200,7 @@ export type Album = Selectable<Albums> & {
 
 export type AuthSession = {
   id: string;
+  hasElevatedPermission: boolean;
 };
 
 export type Partner = {
@@ -233,6 +234,7 @@ export type Session = {
   updatedAt: Date;
   deviceOS: string;
   deviceType: string;
+  pinExpiresAt: Date | null;
 };
 
 export type Exif = Omit<Selectable<DatabaseExif>, 'updatedAt' | 'updateId'>;
@@ -306,7 +308,7 @@ export const columns = {
     'users.quotaSizeInBytes',
   ],
   authApiKey: ['api_keys.id', 'api_keys.permissions'],
-  authSession: ['sessions.id', 'sessions.updatedAt'],
+  authSession: ['sessions.id', 'sessions.updatedAt', 'sessions.pinExpiresAt'],
   authSharedLink: [
     'shared_links.id',
     'shared_links.userId',
diff --git a/server/src/db.d.ts b/server/src/db.d.ts
index 1b039f9982..1fd7fdc22b 100644
--- a/server/src/db.d.ts
+++ b/server/src/db.d.ts
@@ -347,6 +347,7 @@ export interface Sessions {
   updatedAt: Generated<Timestamp>;
   updateId: Generated<string>;
   userId: string;
+  pinExpiresAt: Timestamp | null;
 }
 
 export interface SessionSyncCheckpoints {
diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts
index 480ad0b9b9..2a44a34b58 100644
--- a/server/src/dtos/asset-response.dto.ts
+++ b/server/src/dtos/asset-response.dto.ts
@@ -43,6 +43,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
   isArchived!: boolean;
   isTrashed!: boolean;
   isOffline!: boolean;
+  visibility!: AssetVisibility;
   exifInfo?: ExifResponseDto;
   tags?: TagResponseDto[];
   people?: PersonWithFacesResponseDto[];
@@ -184,6 +185,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
     isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false,
     isArchived: entity.visibility === AssetVisibility.ARCHIVE,
     isTrashed: !!entity.deletedAt,
+    visibility: entity.visibility,
     duration: entity.duration ?? '0:00:00.00000',
     exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
     livePhotoVideoId: entity.livePhotoVideoId,
diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts
index cc05d2d860..8644426ab2 100644
--- a/server/src/dtos/auth.dto.ts
+++ b/server/src/dtos/auth.dto.ts
@@ -138,4 +138,5 @@ export class OAuthAuthorizeResponseDto {
 export class AuthStatusResponseDto {
   pinCode!: boolean;
   password!: boolean;
+  isElevated!: boolean;
 }
diff --git a/server/src/enum.ts b/server/src/enum.ts
index f214593975..fedfaa6b79 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -627,4 +627,5 @@ export enum AssetVisibility {
    * Video part of the LivePhotos and MotionPhotos
    */
   HIDDEN = 'hidden',
+  LOCKED = 'locked',
 }
diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql
index f550c5b0c1..c73f44c19d 100644
--- a/server/src/queries/access.repository.sql
+++ b/server/src/queries/access.repository.sql
@@ -98,6 +98,7 @@ from
 where
   "assets"."id" in ($1)
   and "assets"."ownerId" = $2
+  and "assets"."visibility" != $3
 
 -- AccessRepository.asset.checkPartnerAccess
 select
diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql
index f4eb6a9929..2b351368ef 100644
--- a/server/src/queries/album.repository.sql
+++ b/server/src/queries/album.repository.sql
@@ -392,6 +392,11 @@ where
 order by
   "albums"."createdAt" desc
 
+-- AlbumRepository.removeAssetsFromAll
+delete from "albums_assets_assets"
+where
+  "albums_assets_assets"."assetsId" in ($1)
+
 -- AlbumRepository.getAssetIds
 select
   *
diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql
index eea2356897..c2daa2a49c 100644
--- a/server/src/queries/session.repository.sql
+++ b/server/src/queries/session.repository.sql
@@ -12,6 +12,7 @@ where
 select
   "sessions"."id",
   "sessions"."updatedAt",
+  "sessions"."pinExpiresAt",
   (
     select
       to_json(obj)
diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts
index 5680ce2c64..b25007c4ea 100644
--- a/server/src/repositories/access.repository.ts
+++ b/server/src/repositories/access.repository.ts
@@ -168,7 +168,7 @@ class AssetAccess {
 
   @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
   @ChunkedSet({ paramIndex: 1 })
-  async checkOwnerAccess(userId: string, assetIds: Set<string>) {
+  async checkOwnerAccess(userId: string, assetIds: Set<string>, hasElevatedPermission: boolean | undefined) {
     if (assetIds.size === 0) {
       return new Set<string>();
     }
@@ -178,6 +178,7 @@ class AssetAccess {
       .select('assets.id')
       .where('assets.id', 'in', [...assetIds])
       .where('assets.ownerId', '=', userId)
+      .$if(!hasElevatedPermission, (eb) => eb.where('assets.visibility', '!=', AssetVisibility.LOCKED))
       .execute()
       .then((assets) => new Set(assets.map((asset) => asset.id)));
   }
diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts
index 1768135210..c8bdae6d31 100644
--- a/server/src/repositories/album.repository.ts
+++ b/server/src/repositories/album.repository.ts
@@ -220,8 +220,10 @@ export class AlbumRepository {
     await this.db.deleteFrom('albums').where('ownerId', '=', userId).execute();
   }
 
-  async removeAsset(assetId: string): Promise<void> {
-    await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', '=', assetId).execute();
+  @GenerateSql({ params: [[DummyValue.UUID]] })
+  @Chunked()
+  async removeAssetsFromAll(assetIds: string[]): Promise<void> {
+    await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', 'in', assetIds).execute();
   }
 
   @Chunked({ paramIndex: 1 })
diff --git a/server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts b/server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts
new file mode 100644
index 0000000000..9a344be66d
--- /dev/null
+++ b/server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts
@@ -0,0 +1,9 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely<any>): Promise<void> {
+  await sql`ALTER TYPE "asset_visibility_enum" ADD VALUE IF NOT EXISTS 'locked';`.execute(db);
+}
+
+export async function down(): Promise<void> {
+  // noop
+}
diff --git a/server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts b/server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts
new file mode 100644
index 0000000000..b0f7d072d5
--- /dev/null
+++ b/server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts
@@ -0,0 +1,9 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely<any>): Promise<void> {
+  await sql`ALTER TABLE "sessions" ADD "pinExpiresAt" timestamp with time zone;`.execute(db);
+}
+
+export async function down(db: Kysely<any>): Promise<void> {
+  await sql`ALTER TABLE "sessions" DROP COLUMN "pinExpiresAt";`.execute(db);
+}
diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts
index ad43d0d6e4..090b469b54 100644
--- a/server/src/schema/tables/session.table.ts
+++ b/server/src/schema/tables/session.table.ts
@@ -36,4 +36,7 @@ export class SessionTable {
 
   @UpdateIdColumn({ indexName: 'IDX_sessions_update_id' })
   updateId!: string;
+
+  @Column({ type: 'timestamp with time zone', nullable: true })
+  pinExpiresAt!: Date | null;
 }
diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts
index 9a3bb605f7..c2b792d091 100644
--- a/server/src/services/album.service.spec.ts
+++ b/server/src/services/album.service.spec.ts
@@ -163,7 +163,7 @@ describe(AlbumService.name, () => {
       );
 
       expect(mocks.user.get).toHaveBeenCalledWith('user-id', {});
-      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
+      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false);
       expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', {
         id: albumStub.empty.id,
         userId: 'user-id',
@@ -207,6 +207,7 @@ describe(AlbumService.name, () => {
       expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
         authStub.admin.user.id,
         new Set(['asset-1', 'asset-2']),
+        false,
       );
     });
   });
@@ -688,7 +689,11 @@ describe(AlbumService.name, () => {
         { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
       ]);
 
-      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
+      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
+        authStub.admin.user.id,
+        new Set(['asset-1']),
+        false,
+      );
       expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
     });
 
diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts
index 8490e8aaea..bb8f7115b8 100644
--- a/server/src/services/asset-media.service.spec.ts
+++ b/server/src/services/asset-media.service.spec.ts
@@ -481,7 +481,11 @@ describe(AssetMediaService.name, () => {
     it('should require the asset.download permission', async () => {
       await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
 
-      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
+      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
+        authStub.admin.user.id,
+        new Set(['asset-1']),
+        undefined,
+      );
       expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
       expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
     });
@@ -512,7 +516,7 @@ describe(AssetMediaService.name, () => {
     it('should require asset.view permissions', async () => {
       await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException);
 
-      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
+      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
       expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
       expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
     });
@@ -611,7 +615,7 @@ describe(AssetMediaService.name, () => {
     it('should require asset.view permissions', async () => {
       await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
 
-      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
+      expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
       expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
       expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
     });
diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts
index 1e4cfddcf5..333f4530de 100755
--- a/server/src/services/asset.service.spec.ts
+++ b/server/src/services/asset.service.spec.ts
@@ -122,6 +122,7 @@ describe(AssetService.name, () => {
       expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
         authStub.admin.user.id,
         new Set([assetStub.image.id]),
+        undefined,
       );
     });
 
diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts
index 3ab6fcb8a7..556641fdb0 100644
--- a/server/src/services/asset.service.ts
+++ b/server/src/services/asset.service.ts
@@ -14,7 +14,7 @@ import {
   mapStats,
 } from 'src/dtos/asset.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
-import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum';
+import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
 import { BaseService } from 'src/services/base.service';
 import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
 import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
@@ -125,6 +125,10 @@ export class AssetService extends BaseService {
       options.rating !== undefined
     ) {
       await this.assetRepository.updateAll(ids, options);
+
+      if (options.visibility === AssetVisibility.LOCKED) {
+        await this.albumRepository.removeAssetsFromAll(ids);
+      }
     }
   }
 
diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts
index 82172d6b95..fb1a5ae042 100644
--- a/server/src/services/auth.service.spec.ts
+++ b/server/src/services/auth.service.spec.ts
@@ -253,6 +253,7 @@ describe(AuthService.name, () => {
         id: session.id,
         updatedAt: session.updatedAt,
         user: factory.authUser(),
+        pinExpiresAt: null,
       };
 
       mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -265,7 +266,7 @@ describe(AuthService.name, () => {
         }),
       ).resolves.toEqual({
         user: sessionWithToken.user,
-        session: { id: session.id },
+        session: { id: session.id, hasElevatedPermission: false },
       });
     });
   });
@@ -376,6 +377,7 @@ describe(AuthService.name, () => {
         id: session.id,
         updatedAt: session.updatedAt,
         user: factory.authUser(),
+        pinExpiresAt: null,
       };
 
       mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -388,7 +390,7 @@ describe(AuthService.name, () => {
         }),
       ).resolves.toEqual({
         user: sessionWithToken.user,
-        session: { id: session.id },
+        session: { id: session.id, hasElevatedPermission: false },
       });
     });
 
@@ -398,6 +400,7 @@ describe(AuthService.name, () => {
         id: session.id,
         updatedAt: session.updatedAt,
         user: factory.authUser(),
+        pinExpiresAt: null,
       };
 
       mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -417,6 +420,7 @@ describe(AuthService.name, () => {
         id: session.id,
         updatedAt: session.updatedAt,
         user: factory.authUser(),
+        pinExpiresAt: null,
       };
 
       mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -916,13 +920,17 @@ describe(AuthService.name, () => {
 
   describe('resetPinCode', () => {
     it('should reset the PIN code', async () => {
+      const currentSession = factory.session();
       const user = factory.userAdmin();
       mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
       mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
+      mocks.session.getByUserId.mockResolvedValue([currentSession]);
+      mocks.session.update.mockResolvedValue(currentSession);
 
       await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
 
       expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
+      expect(mocks.session.update).toHaveBeenCalledWith(currentSession.id, { pinExpiresAt: null });
     });
 
     it('should throw if the PIN code does not match', async () => {
diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts
index 65dd84693b..496c252643 100644
--- a/server/src/services/auth.service.ts
+++ b/server/src/services/auth.service.ts
@@ -126,6 +126,10 @@ export class AuthService extends BaseService {
     this.resetPinChecks(user, dto);
 
     await this.userRepository.update(auth.user.id, { pinCode: null });
+    const sessions = await this.sessionRepository.getByUserId(auth.user.id);
+    for (const session of sessions) {
+      await this.sessionRepository.update(session.id, { pinExpiresAt: null });
+    }
   }
 
   async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
@@ -444,10 +448,25 @@ export class AuthService extends BaseService {
         await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() });
       }
 
+      // Pin check
+      let hasElevatedPermission = false;
+
+      if (session.pinExpiresAt) {
+        const pinExpiresAt = DateTime.fromJSDate(session.pinExpiresAt);
+        hasElevatedPermission = pinExpiresAt > now;
+
+        if (hasElevatedPermission && now.plus({ minutes: 5 }) > pinExpiresAt) {
+          await this.sessionRepository.update(session.id, {
+            pinExpiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate(),
+          });
+        }
+      }
+
       return {
         user: session.user,
         session: {
           id: session.id,
+          hasElevatedPermission,
         },
       };
     }
@@ -455,6 +474,23 @@ export class AuthService extends BaseService {
     throw new UnauthorizedException('Invalid user token');
   }
 
+  async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise<void> {
+    const user = await this.userRepository.getForPinCode(auth.user.id);
+    if (!user) {
+      throw new UnauthorizedException();
+    }
+
+    this.resetPinChecks(user, { pinCode: dto.pinCode });
+
+    if (!auth.session) {
+      throw new BadRequestException('Session is missing');
+    }
+
+    await this.sessionRepository.update(auth.session.id, {
+      pinExpiresAt: new Date(DateTime.now().plus({ minutes: 15 }).toJSDate()),
+    });
+  }
+
   private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
     const key = this.cryptoRepository.newPassword(32);
     const token = this.cryptoRepository.hashSha256(key);
@@ -493,6 +529,7 @@ export class AuthService extends BaseService {
     return {
       pinCode: !!user.pinCode,
       password: !!user.password,
+      isElevated: !!auth.session?.hasElevatedPermission,
     };
   }
 }
diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts
index 28cb42a16b..7b2cba1250 100644
--- a/server/src/services/metadata.service.spec.ts
+++ b/server/src/services/metadata.service.spec.ts
@@ -1310,7 +1310,7 @@ describe(MetadataService.name, () => {
       expect(mocks.asset.update).not.toHaveBeenCalledWith(
         expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
       );
-      expect(mocks.album.removeAsset).not.toHaveBeenCalled();
+      expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled();
     });
 
     it('should handle not finding a match', async () => {
@@ -1331,7 +1331,7 @@ describe(MetadataService.name, () => {
       expect(mocks.asset.update).not.toHaveBeenCalledWith(
         expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
       );
-      expect(mocks.album.removeAsset).not.toHaveBeenCalled();
+      expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled();
     });
 
     it('should link photo and video', async () => {
@@ -1356,7 +1356,7 @@ describe(MetadataService.name, () => {
         id: assetStub.livePhotoMotionAsset.id,
         visibility: AssetVisibility.HIDDEN,
       });
-      expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
+      expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
     });
 
     it('should notify clients on live photo link', async () => {
diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts
index 3497b808da..109f5f6936 100644
--- a/server/src/services/metadata.service.ts
+++ b/server/src/services/metadata.service.ts
@@ -158,7 +158,7 @@ export class MetadataService extends BaseService {
     await Promise.all([
       this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }),
       this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.HIDDEN }),
-      this.albumRepository.removeAsset(motionAsset.id),
+      this.albumRepository.removeAssetsFromAll([motionAsset.id]),
     ]);
 
     await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts
index c3ab5619be..6e26b26407 100644
--- a/server/src/services/session.service.spec.ts
+++ b/server/src/services/session.service.spec.ts
@@ -34,6 +34,7 @@ describe('SessionService', () => {
           token: '420',
           userId: '42',
           updateId: 'uuid-v7',
+          pinExpiresAt: null,
         },
       ]);
       mocks.session.delete.mockResolvedValue();
diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts
index 66a0a925c7..b3b4c4b1cf 100644
--- a/server/src/services/shared-link.service.spec.ts
+++ b/server/src/services/shared-link.service.spec.ts
@@ -156,6 +156,7 @@ describe(SharedLinkService.name, () => {
       expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
         authStub.admin.user.id,
         new Set([assetStub.image.id]),
+        false,
       );
       expect(mocks.sharedLink.create).toHaveBeenCalledWith({
         type: SharedLinkType.INDIVIDUAL,
@@ -186,6 +187,7 @@ describe(SharedLinkService.name, () => {
       expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
         authStub.admin.user.id,
         new Set([assetStub.image.id]),
+        false,
       );
       expect(mocks.sharedLink.create).toHaveBeenCalledWith({
         type: SharedLinkType.INDIVIDUAL,
diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts
index b04d23f114..e2fe7429f3 100644
--- a/server/src/utils/access.ts
+++ b/server/src/utils/access.ts
@@ -81,7 +81,7 @@ const checkSharedLinkAccess = async (
 
     case Permission.ASSET_SHARE: {
       // TODO: fix this to not use sharedLink.userId for access control
-      return await access.asset.checkOwnerAccess(sharedLink.userId, ids);
+      return await access.asset.checkOwnerAccess(sharedLink.userId, ids, false);
     }
 
     case Permission.ALBUM_READ: {
@@ -119,38 +119,38 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
     }
 
     case Permission.ASSET_READ: {
-      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
+      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
       const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
       const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
       return setUnion(isOwner, isAlbum, isPartner);
     }
 
     case Permission.ASSET_SHARE: {
-      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
+      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false);
       const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
       return setUnion(isOwner, isPartner);
     }
 
     case Permission.ASSET_VIEW: {
-      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
+      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
       const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
       const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
       return setUnion(isOwner, isAlbum, isPartner);
     }
 
     case Permission.ASSET_DOWNLOAD: {
-      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
+      const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
       const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
       const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
       return setUnion(isOwner, isAlbum, isPartner);
     }
 
     case Permission.ASSET_UPDATE: {
-      return await access.asset.checkOwnerAccess(auth.user.id, ids);
+      return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
     }
 
     case Permission.ASSET_DELETE: {
-      return await access.asset.checkOwnerAccess(auth.user.id, ids);
+      return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
     }
 
     case Permission.ALBUM_READ: {
diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts
index 9ef55398d3..3e5825c0cc 100644
--- a/server/test/fixtures/auth.stub.ts
+++ b/server/test/fixtures/auth.stub.ts
@@ -1,4 +1,4 @@
-import { Session } from 'src/database';
+import { AuthSession } from 'src/database';
 import { AuthDto } from 'src/dtos/auth.dto';
 
 const authUser = {
@@ -26,7 +26,7 @@ export const authStub = {
     user: authUser.user1,
     session: {
       id: 'token-id',
-    } as Session,
+    } as AuthSession,
   }),
   user2: Object.freeze<AuthDto>({
     user: {
@@ -39,7 +39,7 @@ export const authStub = {
     },
     session: {
       id: 'token-id',
-    } as Session,
+    } as AuthSession,
   }),
   adminSharedLink: Object.freeze({
     user: authUser.admin,
diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts
index fc4b74ba2d..f3096280d9 100644
--- a/server/test/fixtures/shared-link.stub.ts
+++ b/server/test/fixtures/shared-link.stub.ts
@@ -70,6 +70,7 @@ const assetResponse: AssetResponseDto = {
   isTrashed: false,
   libraryId: 'library-id',
   hasMetadata: true,
+  visibility: AssetVisibility.TIMELINE,
 };
 
 const assetResponseWithoutMetadata = {
diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts
index 94ae3b74aa..01091854fa 100644
--- a/server/test/small.factory.ts
+++ b/server/test/small.factory.ts
@@ -58,7 +58,7 @@ const authFactory = ({
   }
 
   if (session) {
-    auth.session = { id: session.id };
+    auth.session = { id: session.id, hasElevatedPermission: false };
   }
 
   if (sharedLink) {
@@ -127,6 +127,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
   deviceType: 'mobile',
   token: 'abc123',
   userId: newUuid(),
+  pinExpiresAt: newDate(),
   ...session,
 });
 
diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts
index 40b189080f..d85325b59a 100644
--- a/web/src/lib/components/asset-viewer/actions/action.ts
+++ b/web/src/lib/components/asset-viewer/actions/action.ts
@@ -13,6 +13,8 @@ type ActionMap = {
   [AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
   [AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
   [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
+  [AssetAction.SET_VISIBILITY_LOCKED]: { asset: AssetResponseDto };
+  [AssetAction.SET_VISIBILITY_TIMELINE]: { asset: AssetResponseDto };
 };
 
 export type Action = {
diff --git a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte
new file mode 100644
index 0000000000..6a7f6d3078
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte
@@ -0,0 +1,60 @@
+<script lang="ts">
+  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
+
+  import { AssetAction } from '$lib/constants';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
+  import { handleError } from '$lib/utils/handle-error';
+  import { AssetVisibility, updateAssets, Visibility, type AssetResponseDto } from '@immich/sdk';
+  import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+  import type { OnAction, PreAction } from './action';
+
+  interface Props {
+    asset: AssetResponseDto;
+    onAction: OnAction;
+    preAction: PreAction;
+  }
+
+  let { asset, onAction, preAction }: Props = $props();
+  const isLocked = asset.visibility === Visibility.Locked;
+
+  const toggleLockedVisibility = async () => {
+    const isConfirmed = await modalManager.showDialog({
+      title: isLocked ? $t('remove_from_locked_folder') : $t('move_to_locked_folder'),
+      prompt: isLocked ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
+      confirmText: $t('move'),
+      confirmColor: isLocked ? 'danger' : 'primary',
+    });
+
+    if (!isConfirmed) {
+      return;
+    }
+
+    try {
+      preAction({
+        type: isLocked ? AssetAction.SET_VISIBILITY_TIMELINE : AssetAction.SET_VISIBILITY_LOCKED,
+        asset,
+      });
+
+      await updateAssets({
+        assetBulkUpdateDto: {
+          ids: [asset.id],
+          visibility: isLocked ? AssetVisibility.Timeline : AssetVisibility.Locked,
+        },
+      });
+
+      onAction({
+        type: isLocked ? AssetAction.SET_VISIBILITY_TIMELINE : AssetAction.SET_VISIBILITY_LOCKED,
+        asset,
+      });
+    } catch (error) {
+      handleError(error, $t('errors.unable_to_save_settings'));
+    }
+  };
+</script>
+
+<MenuOption
+  onClick={() => toggleLockedVisibility()}
+  text={isLocked ? $t('move_off_locked_folder') : $t('add_to_locked_folder')}
+  icon={isLocked ? mdiFolderMoveOutline : mdiEyeOffOutline}
+/>
diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
index b0ac455bc8..9436dc13c8 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
@@ -12,6 +12,7 @@
   import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
   import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
   import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
+  import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
   import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
   import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
   import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
@@ -27,6 +28,7 @@
   import {
     AssetJobName,
     AssetTypeEnum,
+    Visibility,
     type AlbumResponseDto,
     type AssetResponseDto,
     type PersonResponseDto,
@@ -91,6 +93,7 @@
   const sharedLink = getSharedLink();
   let isOwner = $derived($user && asset.ownerId === $user?.id);
   let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
+  let isLocked = $derived(asset.visibility === Visibility.Locked);
 
   // $: showEditorButton =
   //   isOwner &&
@@ -112,7 +115,7 @@
     {/if}
   </div>
   <div class="flex gap-2 overflow-x-auto text-white" data-testid="asset-viewer-navbar-actions">
-    {#if !asset.isTrashed && $user}
+    {#if !asset.isTrashed && $user && !isLocked}
       <ShareAction {asset} />
     {/if}
     {#if asset.isOffline}
@@ -159,17 +162,20 @@
       <DeleteAction {asset} {onAction} {preAction} />
 
       <ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
-        {#if showSlideshow}
+        {#if showSlideshow && !isLocked}
           <MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
         {/if}
         {#if showDownloadButton}
           <DownloadAction {asset} menuItem />
         {/if}
-        {#if asset.isTrashed}
-          <RestoreAction {asset} {onAction} />
-        {:else}
-          <AddToAlbumAction {asset} {onAction} />
-          <AddToAlbumAction {asset} {onAction} shared />
+
+        {#if !isLocked}
+          {#if asset.isTrashed}
+            <RestoreAction {asset} {onAction} />
+          {:else}
+            <AddToAlbumAction {asset} {onAction} />
+            <AddToAlbumAction {asset} {onAction} shared />
+          {/if}
         {/if}
 
         {#if isOwner}
@@ -183,21 +189,28 @@
           {#if person}
             <SetFeaturedPhotoAction {asset} {person} />
           {/if}
-          {#if asset.type === AssetTypeEnum.Image}
+          {#if asset.type === AssetTypeEnum.Image && !isLocked}
             <SetProfilePictureAction {asset} />
           {/if}
-          <ArchiveAction {asset} {onAction} {preAction} />
-          <MenuOption
-            icon={mdiUpload}
-            onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
-            text={$t('replace_with_upload')}
-          />
-          {#if !asset.isArchived && !asset.isTrashed}
+
+          {#if !isLocked}
+            <ArchiveAction {asset} {onAction} {preAction} />
             <MenuOption
-              icon={mdiImageSearch}
-              onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
-              text={$t('view_in_timeline')}
+              icon={mdiUpload}
+              onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
+              text={$t('replace_with_upload')}
             />
+            {#if !asset.isArchived && !asset.isTrashed}
+              <MenuOption
+                icon={mdiImageSearch}
+                onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
+                text={$t('view_in_timeline')}
+              />
+            {/if}
+          {/if}
+
+          {#if !asset.isTrashed}
+            <SetVisibilityAction {asset} {onAction} {preAction} />
           {/if}
           <hr />
           <MenuOption
diff --git a/web/src/lib/components/layouts/AuthPageLayout.svelte b/web/src/lib/components/layouts/AuthPageLayout.svelte
index 6feba33c77..33dd2e87be 100644
--- a/web/src/lib/components/layouts/AuthPageLayout.svelte
+++ b/web/src/lib/components/layouts/AuthPageLayout.svelte
@@ -2,11 +2,12 @@
   import { Card, CardBody, CardHeader, Heading, immichLogo, Logo, VStack } from '@immich/ui';
   import type { Snippet } from 'svelte';
   interface Props {
-    title: string;
+    title?: string;
     children?: Snippet;
+    withHeader?: boolean;
   }
 
-  let { title, children }: Props = $props();
+  let { title, children, withHeader = true }: Props = $props();
 </script>
 
 <section class="min-w-dvw flex min-h-dvh items-center justify-center relative">
@@ -18,12 +19,14 @@
   </div>
 
   <Card color="secondary" class="w-full max-w-lg border m-2">
-    <CardHeader class="mt-6">
-      <VStack>
-        <Logo variant="icon" size="giant" />
-        <Heading size="large" class="font-semibold" color="primary" tag="h1">{title}</Heading>
-      </VStack>
-    </CardHeader>
+    {#if withHeader}
+      <CardHeader class="mt-6">
+        <VStack>
+          <Logo variant="icon" size="giant" />
+          <Heading size="large" class="font-semibold" color="primary" tag="h1">{title}</Heading>
+        </VStack>
+      </CardHeader>
+    {/if}
 
     <CardBody class="p-8">
       {@render children?.()}
diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte
index 75bdc0f8a6..5cdcffb937 100644
--- a/web/src/lib/components/photos-page/actions/delete-assets.svelte
+++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte
@@ -1,12 +1,12 @@
 <script lang="ts">
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { featureFlags } from '$lib/stores/server-config.store';
+  import { type OnDelete, deleteAssets } from '$lib/utils/actions';
+  import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js';
+  import { t } from 'svelte-i18n';
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
-  import { featureFlags } from '$lib/stores/server-config.store';
-  import { mdiTimerSand, mdiDeleteOutline, mdiDeleteForeverOutline } from '@mdi/js';
-  import { type OnDelete, deleteAssets } from '$lib/utils/actions';
   import DeleteAssetDialog from '../delete-asset-dialog.svelte';
-  import { t } from 'svelte-i18n';
 
   interface Props {
     onAssetDelete: OnDelete;
diff --git a/web/src/lib/components/photos-page/actions/select-all-assets.svelte b/web/src/lib/components/photos-page/actions/select-all-assets.svelte
index cc3f75ab56..8fa7351609 100644
--- a/web/src/lib/components/photos-page/actions/select-all-assets.svelte
+++ b/web/src/lib/components/photos-page/actions/select-all-assets.svelte
@@ -1,17 +1,19 @@
 <script lang="ts">
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
-  import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
-  import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
-  import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils';
-  import { t } from 'svelte-i18n';
   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
+  import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
+  import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
+  import { Button } from '@immich/ui';
+  import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   interface Props {
     assetStore: AssetStore;
     assetInteraction: AssetInteraction;
+    withText?: boolean;
   }
 
-  let { assetStore, assetInteraction }: Props = $props();
+  let { assetStore, assetInteraction, withText = false }: Props = $props();
 
   const handleSelectAll = async () => {
     await selectAllAssets(assetStore, assetInteraction);
@@ -22,8 +24,20 @@
   };
 </script>
 
-{#if $isSelectingAllAssets}
-  <CircleIconButton title={$t('unselect_all')} icon={mdiSelectRemove} onclick={handleCancel} />
+{#if withText}
+  <Button
+    leadingIcon={$isSelectingAllAssets ? mdiSelectRemove : mdiSelectAll}
+    size="medium"
+    color="secondary"
+    variant="ghost"
+    onclick={$isSelectingAllAssets ? handleCancel : handleSelectAll}
+  >
+    {$isSelectingAllAssets ? $t('unselect_all') : $t('select_all')}
+  </Button>
 {:else}
-  <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
+  <CircleIconButton
+    title={$isSelectingAllAssets ? $t('unselect_all') : $t('select_all')}
+    icon={$isSelectingAllAssets ? mdiSelectRemove : mdiSelectAll}
+    onclick={$isSelectingAllAssets ? handleCancel : handleSelectAll}
+  />
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/set-visibility-action.svelte b/web/src/lib/components/photos-page/actions/set-visibility-action.svelte
new file mode 100644
index 0000000000..c11ba114ce
--- /dev/null
+++ b/web/src/lib/components/photos-page/actions/set-visibility-action.svelte
@@ -0,0 +1,72 @@
+<script lang="ts">
+  import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
+  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
+
+  import type { OnSetVisibility } from '$lib/utils/actions';
+  import { handleError } from '$lib/utils/handle-error';
+  import { AssetVisibility, updateAssets } from '@immich/sdk';
+  import { Button } from '@immich/ui';
+  import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+
+  interface Props {
+    onVisibilitySet: OnSetVisibility;
+    menuItem?: boolean;
+    unlock?: boolean;
+  }
+
+  let { onVisibilitySet, menuItem = false, unlock = false }: Props = $props();
+  let loading = $state(false);
+  const { getAssets } = getAssetControlContext();
+
+  const setLockedVisibility = async () => {
+    const isConfirmed = await modalManager.showDialog({
+      title: unlock ? $t('remove_from_locked_folder') : $t('move_to_locked_folder'),
+      prompt: unlock ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
+      confirmText: $t('move'),
+      confirmColor: unlock ? 'danger' : 'primary',
+    });
+
+    if (!isConfirmed) {
+      return;
+    }
+
+    try {
+      loading = true;
+      const assetIds = getAssets().map(({ id }) => id);
+
+      await updateAssets({
+        assetBulkUpdateDto: {
+          ids: assetIds,
+          visibility: unlock ? AssetVisibility.Timeline : AssetVisibility.Locked,
+        },
+      });
+
+      onVisibilitySet(assetIds);
+    } catch (error) {
+      handleError(error, $t('errors.unable_to_save_settings'));
+    } finally {
+      loading = false;
+    }
+  };
+</script>
+
+{#if menuItem}
+  <MenuOption
+    onClick={setLockedVisibility}
+    text={unlock ? $t('move_off_locked_folder') : $t('add_to_locked_folder')}
+    icon={unlock ? mdiFolderMoveOutline : mdiEyeOffOutline}
+  />
+{:else}
+  <Button
+    leadingIcon={unlock ? mdiFolderMoveOutline : mdiEyeOffOutline}
+    disabled={loading}
+    size="medium"
+    color="secondary"
+    variant="ghost"
+    onclick={setLockedVisibility}
+  >
+    {unlock ? $t('move_off_locked_folder') : $t('add_to_locked_folder')}
+  </Button>
+{/if}
diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte
index dd17874a61..508e3dea6c 100644
--- a/web/src/lib/components/photos-page/asset-grid.svelte
+++ b/web/src/lib/components/photos-page/asset-grid.svelte
@@ -39,7 +39,13 @@
     enableRouting: boolean;
     assetStore: AssetStore;
     assetInteraction: AssetInteraction;
-    removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null;
+    removeAction?:
+      | AssetAction.UNARCHIVE
+      | AssetAction.ARCHIVE
+      | AssetAction.FAVORITE
+      | AssetAction.UNFAVORITE
+      | AssetAction.SET_VISIBILITY_TIMELINE
+      | null;
     withStacked?: boolean;
     showArchiveIcon?: boolean;
     isShared?: boolean;
@@ -417,7 +423,9 @@
       case AssetAction.TRASH:
       case AssetAction.RESTORE:
       case AssetAction.DELETE:
-      case AssetAction.ARCHIVE: {
+      case AssetAction.ARCHIVE:
+      case AssetAction.SET_VISIBILITY_LOCKED:
+      case AssetAction.SET_VISIBILITY_TIMELINE: {
         // find the next asset to show or close the viewer
         // eslint-disable-next-line @typescript-eslint/no-unused-expressions
         (await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset }));
@@ -445,6 +453,7 @@
 
       case AssetAction.UNSTACK: {
         updateUnstackedAssetInTimeline(assetStore, action.assets);
+        break;
       }
     }
   };
diff --git a/web/src/lib/components/shared-components/empty-placeholder.svelte b/web/src/lib/components/shared-components/empty-placeholder.svelte
index 922d7ad92f..63c30a0c4a 100644
--- a/web/src/lib/components/shared-components/empty-placeholder.svelte
+++ b/web/src/lib/components/shared-components/empty-placeholder.svelte
@@ -6,9 +6,10 @@
     text: string;
     fullWidth?: boolean;
     src?: string;
+    title?: string;
   }
 
-  let { onClick = undefined, text, fullWidth = false, src = empty1Url }: Props = $props();
+  let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props();
 
   let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
 
@@ -24,5 +25,9 @@
   class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
 >
   <img {src} alt="" width="500" draggable="false" />
-  <p class="text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
+
+  {#if title}
+    <h2 class="text-xl font-medium my-4">{title}</h2>
+  {/if}
+  <p class="text-immich-text-gray-500 dark:text-immich-dark-fg font-light">{text}</p>
 </svelte:element>
diff --git a/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte b/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte
index 08911b4ef5..74cf69b08e 100644
--- a/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte
+++ b/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte
@@ -19,6 +19,8 @@
     mdiImageMultiple,
     mdiImageMultipleOutline,
     mdiLink,
+    mdiLock,
+    mdiLockOutline,
     mdiMagnify,
     mdiMap,
     mdiMapOutline,
@@ -40,6 +42,7 @@
   let isSharingSelected: boolean = $state(false);
   let isTrashSelected: boolean = $state(false);
   let isUtilitiesSelected: boolean = $state(false);
+  let isLockedFolderSelected: boolean = $state(false);
 </script>
 
 <Sidebar ariaLabel={$t('primary')}>
@@ -128,6 +131,13 @@
     icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
   ></SideBarLink>
 
+  <SideBarLink
+    title={$t('locked_folder')}
+    routeId="/(user)/locked"
+    bind:isSelected={isLockedFolderSelected}
+    icon={isLockedFolderSelected ? mdiLock : mdiLockOutline}
+  ></SideBarLink>
+
   {#if $featureFlags.trash}
     <SideBarLink
       title={$t('trash')}
diff --git a/web/src/lib/components/user-settings-page/PinCodeChangeForm.svelte b/web/src/lib/components/user-settings-page/PinCodeChangeForm.svelte
new file mode 100644
index 0000000000..54bcaca38f
--- /dev/null
+++ b/web/src/lib/components/user-settings-page/PinCodeChangeForm.svelte
@@ -0,0 +1,79 @@
+<script lang="ts">
+  import {
+    notificationController,
+    NotificationType,
+  } from '$lib/components/shared-components/notification/notification';
+  import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
+  import { handleError } from '$lib/utils/handle-error';
+  import { changePinCode } from '@immich/sdk';
+  import { Button } from '@immich/ui';
+  import { t } from 'svelte-i18n';
+  import { fade } from 'svelte/transition';
+
+  let currentPinCode = $state('');
+  let newPinCode = $state('');
+  let confirmPinCode = $state('');
+  let isLoading = $state(false);
+  let canSubmit = $derived(currentPinCode.length === 6 && confirmPinCode.length === 6 && newPinCode === confirmPinCode);
+
+  interface Props {
+    onChanged?: () => void;
+  }
+
+  let { onChanged }: Props = $props();
+
+  const handleSubmit = async (event: Event) => {
+    event.preventDefault();
+    await handleChangePinCode();
+  };
+
+  const handleChangePinCode = async () => {
+    isLoading = true;
+    try {
+      await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
+
+      resetForm();
+
+      notificationController.show({
+        message: $t('pin_code_changed_successfully'),
+        type: NotificationType.Info,
+      });
+
+      onChanged?.();
+    } catch (error) {
+      handleError(error, $t('unable_to_change_pin_code'));
+    } finally {
+      isLoading = false;
+    }
+  };
+
+  const resetForm = () => {
+    currentPinCode = '';
+    newPinCode = '';
+    confirmPinCode = '';
+  };
+</script>
+
+<section class="my-4">
+  <div in:fade={{ duration: 200 }}>
+    <form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
+      <div class="flex flex-col gap-6 place-items-center place-content-center">
+        <p class="text-dark">{$t('change_pin_code')}</p>
+        <PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
+
+        <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
+
+        <PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
+      </div>
+
+      <div class="flex justify-end gap-2 mt-4">
+        <Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
+          {$t('clear')}
+        </Button>
+        <Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
+          {$t('save')}
+        </Button>
+      </div>
+    </form>
+  </div>
+</section>
diff --git a/web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte b/web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte
new file mode 100644
index 0000000000..ae07e976b7
--- /dev/null
+++ b/web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte
@@ -0,0 +1,72 @@
+<script lang="ts">
+  import {
+    notificationController,
+    NotificationType,
+  } from '$lib/components/shared-components/notification/notification';
+  import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
+  import { handleError } from '$lib/utils/handle-error';
+  import { setupPinCode } from '@immich/sdk';
+  import { Button } from '@immich/ui';
+  import { t } from 'svelte-i18n';
+
+  interface Props {
+    onCreated?: (pinCode: string) => void;
+    showLabel?: boolean;
+  }
+
+  let { onCreated, showLabel = true }: Props = $props();
+
+  let newPinCode = $state('');
+  let confirmPinCode = $state('');
+  let isLoading = $state(false);
+  let canSubmit = $derived(confirmPinCode.length === 6 && newPinCode === confirmPinCode);
+
+  const handleSubmit = async (event: Event) => {
+    event.preventDefault();
+    await createPinCode();
+  };
+
+  const createPinCode = async () => {
+    isLoading = true;
+    try {
+      await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
+
+      notificationController.show({
+        message: $t('pin_code_setup_successfully'),
+        type: NotificationType.Info,
+      });
+
+      onCreated?.(newPinCode);
+      resetForm();
+    } catch (error) {
+      handleError(error, $t('unable_to_setup_pin_code'));
+    } finally {
+      isLoading = false;
+    }
+  };
+
+  const resetForm = () => {
+    newPinCode = '';
+    confirmPinCode = '';
+  };
+</script>
+
+<form autocomplete="off" onsubmit={handleSubmit}>
+  <div class="flex flex-col gap-6 place-items-center place-content-center">
+    {#if showLabel}
+      <p class="text-dark">{$t('setup_pin_code')}</p>
+    {/if}
+    <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
+
+    <PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={7} pinLength={6} />
+  </div>
+
+  <div class="flex justify-end gap-2 mt-4">
+    <Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
+      {$t('clear')}
+    </Button>
+    <Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
+      {$t('create')}
+    </Button>
+  </div>
+</form>
diff --git a/web/src/lib/components/user-settings-page/PinCodeInput.svelte b/web/src/lib/components/user-settings-page/PinCodeInput.svelte
index e149f26851..01de7b3563 100644
--- a/web/src/lib/components/user-settings-page/PinCodeInput.svelte
+++ b/web/src/lib/components/user-settings-page/PinCodeInput.svelte
@@ -1,12 +1,25 @@
 <script lang="ts">
+  import { onMount } from 'svelte';
+
   interface Props {
     label: string;
     value?: string;
     pinLength?: number;
     tabindexStart?: number;
+    autofocus?: boolean;
+    onFilled?: (value: string) => void;
+    type?: 'text' | 'password';
   }
 
-  let { label, value = $bindable(''), pinLength = 6, tabindexStart = 0 }: Props = $props();
+  let {
+    label,
+    value = $bindable(''),
+    pinLength = 6,
+    tabindexStart = 0,
+    autofocus = false,
+    onFilled,
+    type = 'text',
+  }: Props = $props();
 
   let pinValues = $state(Array.from({ length: pinLength }).fill(''));
   let pinCodeInputElements: HTMLInputElement[] = $state([]);
@@ -17,6 +30,12 @@
     }
   });
 
+  onMount(() => {
+    if (autofocus) {
+      pinCodeInputElements[0]?.focus();
+    }
+  });
+
   const focusNext = (index: number) => {
     pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
   };
@@ -48,6 +67,10 @@
     if (value && index < pinLength - 1) {
       focusNext(index);
     }
+
+    if (value.length === pinLength) {
+      onFilled?.(value);
+    }
   };
 
   function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
@@ -97,13 +120,13 @@
     {#each { length: pinLength } as _, index (index)}
       <input
         tabindex={tabindexStart + index}
-        type="text"
+        {type}
         inputmode="numeric"
         pattern="[0-9]*"
         maxlength="1"
         bind:this={pinCodeInputElements[index]}
         id="pin-code-{index}"
-        class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono"
+        class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light"
         bind:value={pinValues[index]}
         onkeydown={handleKeydown}
         oninput={(event) => handleInput(event, index)}
diff --git a/web/src/lib/components/user-settings-page/PinCodeSettings.svelte b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte
index 4839b2d58c..e7e36977a8 100644
--- a/web/src/lib/components/user-settings-page/PinCodeSettings.svelte
+++ b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte
@@ -1,116 +1,26 @@
 <script lang="ts">
-  import {
-    notificationController,
-    NotificationType,
-  } from '$lib/components/shared-components/notification/notification';
-  import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
-  import { handleError } from '$lib/utils/handle-error';
-  import { changePinCode, getAuthStatus, setupPinCode } from '@immich/sdk';
-  import { Button } from '@immich/ui';
+  import PinCodeChangeForm from '$lib/components/user-settings-page/PinCodeChangeForm.svelte';
+  import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
+  import { getAuthStatus } from '@immich/sdk';
   import { onMount } from 'svelte';
-  import { t } from 'svelte-i18n';
   import { fade } from 'svelte/transition';
 
   let hasPinCode = $state(false);
-  let currentPinCode = $state('');
-  let newPinCode = $state('');
-  let confirmPinCode = $state('');
-  let isLoading = $state(false);
-  let canSubmit = $derived(
-    (hasPinCode ? currentPinCode.length === 6 : true) && confirmPinCode.length === 6 && newPinCode === confirmPinCode,
-  );
 
   onMount(async () => {
-    const authStatus = await getAuthStatus();
-    hasPinCode = authStatus.pinCode;
+    const { pinCode } = await getAuthStatus();
+    hasPinCode = pinCode;
   });
-
-  const handleSubmit = async (event: Event) => {
-    event.preventDefault();
-    await (hasPinCode ? handleChange() : handleSetup());
-  };
-
-  const handleSetup = async () => {
-    isLoading = true;
-    try {
-      await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
-
-      resetForm();
-
-      notificationController.show({
-        message: $t('pin_code_setup_successfully'),
-        type: NotificationType.Info,
-      });
-    } catch (error) {
-      handleError(error, $t('unable_to_setup_pin_code'));
-    } finally {
-      isLoading = false;
-      hasPinCode = true;
-    }
-  };
-
-  const handleChange = async () => {
-    isLoading = true;
-    try {
-      await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
-
-      resetForm();
-
-      notificationController.show({
-        message: $t('pin_code_changed_successfully'),
-        type: NotificationType.Info,
-      });
-    } catch (error) {
-      handleError(error, $t('unable_to_change_pin_code'));
-    } finally {
-      isLoading = false;
-    }
-  };
-
-  const resetForm = () => {
-    currentPinCode = '';
-    newPinCode = '';
-    confirmPinCode = '';
-  };
 </script>
 
 <section class="my-4">
-  <div in:fade={{ duration: 200 }}>
-    <form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
-      <div class="flex flex-col gap-6 place-items-center place-content-center">
-        {#if hasPinCode}
-          <p class="text-dark">{$t('change_pin_code')}</p>
-          <PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
-
-          <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
-
-          <PinCodeInput
-            label={$t('confirm_new_pin_code')}
-            bind:value={confirmPinCode}
-            tabindexStart={13}
-            pinLength={6}
-          />
-        {:else}
-          <p class="text-dark">{$t('setup_pin_code')}</p>
-          <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
-
-          <PinCodeInput
-            label={$t('confirm_new_pin_code')}
-            bind:value={confirmPinCode}
-            tabindexStart={7}
-            pinLength={6}
-          />
-        {/if}
-      </div>
-
-      <div class="flex justify-end gap-2 mt-4">
-        <Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
-          {$t('clear')}
-        </Button>
-        <Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
-          {hasPinCode ? $t('save') : $t('create')}
-        </Button>
-      </div>
-    </form>
-  </div>
+  {#if hasPinCode}
+    <div in:fade={{ duration: 200 }} class="mt-6">
+      <PinCodeChangeForm />
+    </div>
+  {:else}
+    <div in:fade={{ duration: 200 }} class="mt-6">
+      <PinCodeCreateForm onCreated={() => (hasPinCode = true)} />
+    </div>
+  {/if}
 </section>
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
index e4603217e0..167c976eeb 100644
--- a/web/src/lib/constants.ts
+++ b/web/src/lib/constants.ts
@@ -10,6 +10,8 @@ export enum AssetAction {
   ADD_TO_ALBUM = 'add-to-album',
   UNSTACK = 'unstack',
   KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others',
+  SET_VISIBILITY_LOCKED = 'set-visibility-locked',
+  SET_VISIBILITY_TIMELINE = 'set-visibility-timeline',
 }
 
 export enum AppRoute {
@@ -43,12 +45,14 @@ export enum AppRoute {
   AUTH_REGISTER = '/auth/register',
   AUTH_CHANGE_PASSWORD = '/auth/change-password',
   AUTH_ONBOARDING = '/auth/onboarding',
+  AUTH_PIN_PROMPT = '/auth/pin-prompt',
 
   UTILITIES = '/utilities',
   DUPLICATES = '/utilities/duplicates',
 
   FOLDERS = '/folders',
   TAGS = '/tags',
+  LOCKED = '/locked',
 }
 
 export enum ProjectionType {
diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts
index 472f55cbca..45fc21a7d9 100644
--- a/web/src/lib/utils/actions.ts
+++ b/web/src/lib/utils/actions.ts
@@ -15,6 +15,7 @@ export type OnArchive = (ids: string[], isArchived: boolean) => void;
 export type OnFavorite = (ids: string[], favorite: boolean) => void;
 export type OnStack = (result: StackResponse) => void;
 export type OnUnstack = (assets: AssetResponseDto[]) => void;
+export type OnSetVisibility = (ids: string[]) => void;
 
 export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
   const $t = get(t);
diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte
new file mode 100644
index 0000000000..49b40866dd
--- /dev/null
+++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -0,0 +1,76 @@
+<script lang="ts">
+  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
+  import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
+  import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
+  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
+  import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
+  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
+  import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
+  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
+  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
+  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
+  import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
+  import { AssetAction } from '$lib/constants';
+  import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
+  import { AssetStore } from '$lib/stores/assets-store.svelte';
+  import { AssetVisibility } from '@immich/sdk';
+  import { mdiDotsVertical } from '@mdi/js';
+  import { onDestroy } from 'svelte';
+  import { t } from 'svelte-i18n';
+  import type { PageData } from './$types';
+
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
+
+  const assetStore = new AssetStore();
+  void assetStore.updateOptions({ visibility: AssetVisibility.Locked });
+  onDestroy(() => assetStore.destroy());
+
+  const assetInteraction = new AssetInteraction();
+
+  const handleEscape = () => {
+    if (assetInteraction.selectionActive) {
+      assetInteraction.clearMultiselect();
+      return;
+    }
+  };
+
+  const handleMoveOffLockedFolder = (assetIds: string[]) => {
+    assetInteraction.clearMultiselect();
+    assetStore.removeAssets(assetIds);
+  };
+</script>
+
+<!-- Multi-selection mode app bar -->
+{#if assetInteraction.selectionActive}
+  <AssetSelectControlBar
+    assets={assetInteraction.selectedAssets}
+    clearSelect={() => assetInteraction.clearMultiselect()}
+  >
+    <SelectAllAssets withText {assetStore} {assetInteraction} />
+    <SetVisibilityAction unlock onVisibilitySet={handleMoveOffLockedFolder} />
+    <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
+      <DownloadAction menuItem />
+      <ChangeDate menuItem />
+      <ChangeLocation menuItem />
+      <DeleteAssets menuItem force onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
+    </ButtonContextMenu>
+  </AssetSelectControlBar>
+{/if}
+
+<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
+  <AssetGrid
+    enableRouting={true}
+    {assetStore}
+    {assetInteraction}
+    onEscape={handleEscape}
+    removeAction={AssetAction.SET_VISIBILITY_TIMELINE}
+  >
+    {#snippet empty()}
+      <EmptyPlaceholder text={$t('no_locked_photos_message')} title={$t('nothing_here_yet')} />
+    {/snippet}
+  </AssetGrid>
+</UserPageLayout>
diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts
new file mode 100644
index 0000000000..9b9d86a4b3
--- /dev/null
+++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts
@@ -0,0 +1,28 @@
+import { AppRoute } from '$lib/constants';
+import { authenticate } from '$lib/utils/auth';
+import { getFormatter } from '$lib/utils/i18n';
+import { getAssetInfoFromParam } from '$lib/utils/navigation';
+import { getAuthStatus } from '@immich/sdk';
+import { redirect } from '@sveltejs/kit';
+import type { PageLoad } from './$types';
+
+export const load = (async ({ params, url }) => {
+  await authenticate();
+  const { isElevated, pinCode } = await getAuthStatus();
+
+  if (!isElevated || !pinCode) {
+    const continuePath = encodeURIComponent(url.pathname);
+    const redirectPath = `${AppRoute.AUTH_PIN_PROMPT}?continue=${continuePath}`;
+
+    redirect(302, redirectPath);
+  }
+  const asset = await getAssetInfoFromParam(params);
+  const $t = await getFormatter();
+
+  return {
+    asset,
+    meta: {
+      title: $t('locked_folder'),
+    },
+  };
+}) satisfies PageLoad;
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 73f04380a5..20f4ca0abc 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -12,6 +12,7 @@
   import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
   import LinkLivePhotoAction from '$lib/components/photos-page/actions/link-live-photo-action.svelte';
   import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
+  import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
   import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
   import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
@@ -75,6 +76,11 @@
     assetStore.updateAssets([still]);
   };
 
+  const handleSetVisibility = (assetIds: string[]) => {
+    assetStore.removeAssets(assetIds);
+    assetInteraction.clearMultiselect();
+  };
+
   beforeNavigate(() => {
     isFaceEditMode.value = false;
   });
@@ -142,6 +148,7 @@
         <TagAction menuItem />
       {/if}
       <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
+      <SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
       <hr />
       <AssetJobActions />
     </ButtonContextMenu>
diff --git a/web/src/routes/auth/pin-prompt/+page.svelte b/web/src/routes/auth/pin-prompt/+page.svelte
new file mode 100644
index 0000000000..91480cd35c
--- /dev/null
+++ b/web/src/routes/auth/pin-prompt/+page.svelte
@@ -0,0 +1,84 @@
+<script lang="ts">
+  import { goto } from '$app/navigation';
+  import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
+  import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
+  import PincodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
+  import { AppRoute } from '$lib/constants';
+  import { handleError } from '$lib/utils/handle-error';
+  import { verifyPinCode } from '@immich/sdk';
+  import { Icon } from '@immich/ui';
+  import { mdiLockOpenVariantOutline, mdiLockOutline, mdiLockSmart } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+  import { fade } from 'svelte/transition';
+  import type { PageData } from './$types';
+
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
+
+  let isVerified = $state(false);
+  let isBadPinCode = $state(false);
+  let hasPinCode = $derived(data.hasPinCode);
+  let pinCode = $state('');
+
+  const onPinFilled = async (code: string, withDelay = false) => {
+    try {
+      await verifyPinCode({ pinCodeSetupDto: { pinCode: code } });
+
+      isVerified = true;
+
+      if (withDelay) {
+        await new Promise((resolve) => setTimeout(resolve, 1000));
+      }
+
+      void goto(data.continuePath ?? AppRoute.LOCKED);
+    } catch (error) {
+      handleError(error, $t('wrong_pin_code'));
+      isBadPinCode = true;
+    }
+  };
+</script>
+
+<AuthPageLayout withHeader={false}>
+  {#if hasPinCode}
+    <div class="flex items-center justify-center">
+      <div class="w-96 flex flex-col gap-6 items-center justify-center">
+        {#if isVerified}
+          <div in:fade={{ duration: 200 }}>
+            <Icon icon={mdiLockOpenVariantOutline} size="64" class="text-success/90" />
+          </div>
+        {:else}
+          <div class:text-danger={isBadPinCode} class:text-primary={!isBadPinCode}>
+            <Icon icon={mdiLockOutline} size="64" />
+          </div>
+        {/if}
+
+        <p class="text-center text-sm" style="text-wrap: pretty;">{$t('enter_your_pin_code_subtitle')}</p>
+
+        <PincodeInput
+          type="password"
+          autofocus
+          label=""
+          bind:value={pinCode}
+          tabindexStart={1}
+          pinLength={6}
+          onFilled={(pinCode) => onPinFilled(pinCode, true)}
+        />
+      </div>
+    </div>
+  {:else}
+    <div class="flex items-center justify-center">
+      <div class="w-96 flex flex-col gap-6 items-center justify-center">
+        <div class="text-primary">
+          <Icon icon={mdiLockSmart} size="64" />
+        </div>
+        <p class="text-center text-sm mb-4" style="text-wrap: pretty;">
+          {$t('new_pin_code_subtitle')}
+        </p>
+        <PinCodeCreateForm showLabel={false} onCreated={() => (hasPinCode = true)} />
+      </div>
+    </div>
+  {/if}
+</AuthPageLayout>
diff --git a/web/src/routes/auth/pin-prompt/+page.ts b/web/src/routes/auth/pin-prompt/+page.ts
new file mode 100644
index 0000000000..e2b79605d8
--- /dev/null
+++ b/web/src/routes/auth/pin-prompt/+page.ts
@@ -0,0 +1,22 @@
+import { authenticate } from '$lib/utils/auth';
+import { getFormatter } from '$lib/utils/i18n';
+import { getAuthStatus } from '@immich/sdk';
+import type { PageLoad } from './$types';
+
+export const load = (async ({ url }) => {
+  await authenticate();
+
+  const { pinCode } = await getAuthStatus();
+
+  const continuePath = url.searchParams.get('continue');
+
+  const $t = await getFormatter();
+
+  return {
+    meta: {
+      title: $t('pin_verification'),
+    },
+    hasPinCode: !!pinCode,
+    continuePath,
+  };
+}) satisfies PageLoad;
diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts
index 656c4143a7..b727286590 100644
--- a/web/src/test-data/factories/asset-factory.ts
+++ b/web/src/test-data/factories/asset-factory.ts
@@ -1,5 +1,5 @@
 import { faker } from '@faker-js/faker';
-import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
+import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk';
 import { Sync } from 'factory.ts';
 
 export const assetFactory = Sync.makeFactory<AssetResponseDto>({
@@ -24,4 +24,5 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
   checksum: Sync.each(() => faker.string.alphanumeric(28)),
   isOffline: Sync.each(() => faker.datatype.boolean()),
   hasMetadata: Sync.each(() => faker.datatype.boolean()),
+  visibility: Visibility.Timeline,
 });