diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 8ea5450b4e85437b473c56e18c9c7833f80dff59..fad036b8df8ccf6dba31fa671983b166c7ff9eda 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -137,7 +137,7 @@ class SnippetsController < ApplicationController
 
   def move_temporary_files
     params[:files].each do |file|
-      FileMover.new(file, @snippet).execute
+      FileMover.new(file, from_model: current_user, to_model: @snippet).execute
     end
   end
 end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 5d28635232b35d63ce1a678245fb9f5751be451d..94bd18f70d4b901593333651a0ad43b4e38fbb8c 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -41,7 +41,11 @@ class UploadsController < ApplicationController
       when Note
         can?(current_user, :read_project, model.project)
       when User
-        true
+        # We validate the current user has enough (writing)
+        # access to itself when a secret is given.
+        # For instance, user avatars are readable by anyone,
+        # while temporary, user snippet uploads are not.
+        !secret? || can?(current_user, :update_user, model)
       when Appearance
         true
       else
@@ -56,9 +60,13 @@ class UploadsController < ApplicationController
   def authorize_create_access!
     return unless model
 
-    # for now we support only personal snippets comments. Only personal_snippet
-    # is allowed as a model to #create through routing.
-    authorized = can?(current_user, :create_note, model)
+    authorized =
+      case model
+      when User
+        can?(current_user, :update_user, model)
+      else
+        can?(current_user, :create_note, model)
+      end
 
     render_unauthorized unless authorized
   end
@@ -75,6 +83,10 @@ class UploadsController < ApplicationController
     User === model || Appearance === model
   end
 
+  def secret?
+    params[:secret].present?
+  end
+
   def upload_model_class
     MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError)
   end
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index ecb2b2d707b065fcd5e92205ae43bc7d247ab591..6ccc1fb2ed1ffb2562f5692c9d6fa8a4a1f02206 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -1,6 +1,16 @@
 # frozen_string_literal: true
 
 module SnippetsHelper
+  def snippets_upload_path(snippet, user)
+    return unless user
+
+    if snippet&.persisted?
+      upload_path('personal_snippet', id: snippet.id)
+    else
+      upload_path('user', id: user.id)
+    end
+  end
+
   def reliable_snippet_path(snippet, opts = nil)
     if snippet.project_id?
       project_snippet_path(snippet.project, snippet, opts)
diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb
index 236b7ed2b3d43663d70f88043032c834fdc39159..dcf1e8792ad8e66854f455c06839265cccbf77da 100644
--- a/app/uploaders/file_mover.rb
+++ b/app/uploaders/file_mover.rb
@@ -1,12 +1,13 @@
 # frozen_string_literal: true
 
 class FileMover
-  attr_reader :secret, :file_name, :model, :update_field
+  attr_reader :secret, :file_name, :from_model, :to_model, :update_field
 
-  def initialize(file_path, model, update_field = :description)
+  def initialize(file_path, update_field = :description, from_model:, to_model:)
     @secret = File.split(File.dirname(file_path)).last
     @file_name = File.basename(file_path)
-    @model = model
+    @from_model = from_model
+    @to_model = to_model
     @update_field = update_field
   end
 
@@ -16,7 +17,7 @@ class FileMover
     move
 
     if update_markdown
-      uploader.record_upload
+      update_upload_model
       uploader.schedule_background_upload
     end
   end
@@ -35,14 +36,20 @@ class FileMover
   end
 
   def update_markdown
-    updated_text = model.read_attribute(update_field)
-                        .gsub(temp_file_uploader.markdown_link, uploader.markdown_link)
-    model.update_attribute(update_field, updated_text)
+    updated_text = to_model.read_attribute(update_field)
+                           .gsub(temp_file_uploader.markdown_link, uploader.markdown_link)
+    to_model.update_attribute(update_field, updated_text)
   rescue
     revert
     false
   end
 
+  def update_upload_model
+    return unless upload = temp_file_uploader.upload
+
+    upload.update!(model_id: to_model.id, model_type: to_model.type)
+  end
+
   def temp_file_path
     return @temp_file_path if @temp_file_path
 
@@ -60,15 +67,15 @@ class FileMover
   end
 
   def uploader
-    @uploader ||= PersonalFileUploader.new(model, secret: secret)
+    @uploader ||= PersonalFileUploader.new(to_model, secret: secret)
   end
 
   def temp_file_uploader
-    @temp_file_uploader ||= PersonalFileUploader.new(nil, secret: secret)
+    @temp_file_uploader ||= PersonalFileUploader.new(from_model, secret: secret)
   end
 
   def revert
-    Rails.logger.warn("Markdown not updated, file move reverted for #{model}")
+    Rails.logger.warn("Markdown not updated, file move reverted for #{to_model}")
 
     FileUtils.move(file_path, temp_file_path)
   end
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 5f986c81ff4c317010541f8a58b001ad9a2b558b..841b2a5e79cf16a7fb34196c4619efe1ccc5a7ef 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -1,9 +1,10 @@
 - header_title _("Snippets"), snippets_path
+- snippets_upload_path = snippets_upload_path(@snippet, current_user)
 
 - content_for :page_specific_javascripts do
-  - if @snippet && current_user
+  - if snippets_upload_path
     -# haml-lint:disable InlineJavaScript
     :javascript
-      window.uploads_path = "#{upload_path('personal_snippet', id: @snippet.id)}";
+      window.uploads_path = "#{snippets_upload_path}";
 
 = render template: "layouts/application"
diff --git a/changelogs/unreleased/osw-persist-tmp-snippet-uploads.yml b/changelogs/unreleased/osw-persist-tmp-snippet-uploads.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9348626c41dd1b246c4b3c58b921f035fcbf40b8
--- /dev/null
+++ b/changelogs/unreleased/osw-persist-tmp-snippet-uploads.yml
@@ -0,0 +1,5 @@
+---
+title: Persist tmp snippet uploads at users
+merge_request:
+author:
+type: security
diff --git a/config/routes/uploads.rb b/config/routes/uploads.rb
index b594f55f8a054ee3ba194d985d4ace9246cab3ad..920f8454ce2c991bdaf27ba6bf86c7ecc7dc48ed 100644
--- a/config/routes/uploads.rb
+++ b/config/routes/uploads.rb
@@ -7,7 +7,7 @@ scope path: :uploads do
   # show uploads for models, snippets (notes) available for now
   get '-/system/:model/:id/:secret/:filename',
     to: 'uploads#show',
-    constraints: { model: /personal_snippet/, id: /\d+/, filename: %r{[^/]+} }
+    constraints: { model: /personal_snippet|user/, id: /\d+/, filename: %r{[^/]+} }
 
   # show temporary uploads
   get '-/system/temp/:secret/:filename',
@@ -28,7 +28,7 @@ scope path: :uploads do
   # create uploads for models, snippets (notes) available for now
   post ':model',
     to: 'uploads#create',
-    constraints: { model: /personal_snippet/, id: /\d+/ },
+    constraints: { model: /personal_snippet|user/, id: /\d+/ },
     as: 'upload'
 end
 
diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb
index f8666a1986f37128654de0c2b9d60d2b4e61dc16..3aba02bf3ff58905168d826520a9fb0c5e497472 100644
--- a/spec/controllers/snippets_controller_spec.rb
+++ b/spec/controllers/snippets_controller_spec.rb
@@ -209,8 +209,8 @@ describe SnippetsController do
     context 'when the snippet description contains a file' do
       include FileMoverHelpers
 
-      let(:picture_file) { '/-/system/temp/secret56/picture.jpg' }
-      let(:text_file) { '/-/system/temp/secret78/text.txt' }
+      let(:picture_file) { "/-/system/user/#{user.id}/secret56/picture.jpg" }
+      let(:text_file) { "/-/system/user/#{user.id}/secret78/text.txt" }
       let(:description) do
         "Description with picture: ![picture](/uploads#{picture_file}) and "\
         "text: [text.txt](/uploads#{text_file})"
diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb
index d27658e02cbd4b59a4bfb9cb81d421642ed8240c..0876502a899a8d612e36193c523bee0b9505bf47 100644
--- a/spec/controllers/uploads_controller_spec.rb
+++ b/spec/controllers/uploads_controller_spec.rb
@@ -24,121 +24,160 @@ describe UploadsController do
   let!(:user) { create(:user, avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) }
 
   describe 'POST create' do
-    let(:model)   { 'personal_snippet' }
-    let(:snippet) { create(:personal_snippet, :public) }
     let(:jpg)     { fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') }
     let(:txt)     { fixture_file_upload('spec/fixtures/doc_sample.txt', 'text/plain') }
 
-    context 'when a user does not have permissions to upload a file' do
-      it "returns 401 when the user is not logged in" do
-        post :create, params: { model: model, id: snippet.id }, format: :json
+    context 'snippet uploads' do
+      let(:model)   { 'personal_snippet' }
+      let(:snippet) { create(:personal_snippet, :public) }
 
-        expect(response).to have_gitlab_http_status(401)
-      end
+      context 'when a user does not have permissions to upload a file' do
+        it "returns 401 when the user is not logged in" do
+          post :create, params: { model: model, id: snippet.id }, format: :json
 
-      it "returns 404 when user can't comment on a snippet" do
-        private_snippet = create(:personal_snippet, :private)
+          expect(response).to have_gitlab_http_status(401)
+        end
 
-        sign_in(user)
-        post :create, params: { model: model, id: private_snippet.id }, format: :json
+        it "returns 404 when user can't comment on a snippet" do
+          private_snippet = create(:personal_snippet, :private)
 
-        expect(response).to have_gitlab_http_status(404)
-      end
-    end
+          sign_in(user)
+          post :create, params: { model: model, id: private_snippet.id }, format: :json
 
-    context 'when a user is logged in' do
-      before do
-        sign_in(user)
+          expect(response).to have_gitlab_http_status(404)
+        end
       end
 
-      it "returns an error without file" do
-        post :create, params: { model: model, id: snippet.id }, format: :json
+      context 'when a user is logged in' do
+        before do
+          sign_in(user)
+        end
 
-        expect(response).to have_gitlab_http_status(422)
-      end
+        it "returns an error without file" do
+          post :create, params: { model: model, id: snippet.id }, format: :json
 
-      it "returns an error with invalid model" do
-        expect { post :create, params: { model: 'invalid', id: snippet.id }, format: :json }
-        .to raise_error(ActionController::UrlGenerationError)
-      end
+          expect(response).to have_gitlab_http_status(422)
+        end
 
-      it "returns 404 status when object not found" do
-        post :create, params: { model: model, id: 9999 }, format: :json
+        it "returns an error with invalid model" do
+          expect { post :create, params: { model: 'invalid', id: snippet.id }, format: :json }
+            .to raise_error(ActionController::UrlGenerationError)
+        end
 
-        expect(response).to have_gitlab_http_status(404)
-      end
+        it "returns 404 status when object not found" do
+          post :create, params: { model: model, id: 9999 }, format: :json
 
-      context 'with valid image' do
-        before do
-          post :create, params: { model: 'personal_snippet', id: snippet.id, file: jpg }, format: :json
+          expect(response).to have_gitlab_http_status(404)
         end
 
-        it 'returns a content with original filename, new link, and correct type.' do
-          expect(response.body).to match '\"alt\":\"rails_sample\"'
-          expect(response.body).to match "\"url\":\"/uploads"
+        context 'with valid image' do
+          before do
+            post :create, params: { model: 'personal_snippet', id: snippet.id, file: jpg }, format: :json
+          end
+
+          it 'returns a content with original filename, new link, and correct type.' do
+            expect(response.body).to match '\"alt\":\"rails_sample\"'
+            expect(response.body).to match "\"url\":\"/uploads"
+          end
+
+          it 'creates a corresponding Upload record' do
+            upload = Upload.last
+
+            aggregate_failures do
+              expect(upload).to exist
+              expect(upload.model).to eq snippet
+            end
+          end
         end
 
-        it 'creates a corresponding Upload record' do
-          upload = Upload.last
+        context 'with valid non-image file' do
+          before do
+            post :create, params: { model: 'personal_snippet', id: snippet.id, file: txt }, format: :json
+          end
 
-          aggregate_failures do
-            expect(upload).to exist
-            expect(upload.model).to eq snippet
+          it 'returns a content with original filename, new link, and correct type.' do
+            expect(response.body).to match '\"alt\":\"doc_sample.txt\"'
+            expect(response.body).to match "\"url\":\"/uploads"
+          end
+
+          it 'creates a corresponding Upload record' do
+            upload = Upload.last
+
+            aggregate_failures do
+              expect(upload).to exist
+              expect(upload.model).to eq snippet
+            end
           end
         end
       end
+    end
+
+    context 'user uploads' do
+      let(:model) { 'user' }
+
+      it 'returns 401 when the user has no access' do
+        post :create, params: { model: 'user', id: user.id }, format: :json
 
-      context 'with valid non-image file' do
+        expect(response).to have_gitlab_http_status(401)
+      end
+
+      context 'when user is logged in' do
         before do
-          post :create, params: { model: 'personal_snippet', id: snippet.id, file: txt }, format: :json
+          sign_in(user)
+        end
+
+        subject do
+          post :create, params: { model: model, id: user.id, file: jpg }, format: :json
         end
 
         it 'returns a content with original filename, new link, and correct type.' do
-          expect(response.body).to match '\"alt\":\"doc_sample.txt\"'
-          expect(response.body).to match "\"url\":\"/uploads"
+          subject
+
+          expect(response.body).to match '\"alt\":\"rails_sample\"'
+          expect(response.body).to match "\"url\":\"/uploads/-/system/user/#{user.id}/"
         end
 
         it 'creates a corresponding Upload record' do
+          expect { subject }.to change { Upload.count }
+
           upload = Upload.last
 
           aggregate_failures do
             expect(upload).to exist
-            expect(upload.model).to eq snippet
+            expect(upload.model).to eq user
           end
         end
-      end
 
-      context 'temporal with valid image' do
-        subject do
-          post :create, params: { model: 'personal_snippet', file: jpg }, format: :json
-        end
+        context 'with valid non-image file' do
+          subject do
+            post :create, params: { model: model, id: user.id, file: txt }, format: :json
+          end
 
-        it 'returns a content with original filename, new link, and correct type.' do
-          subject
+          it 'returns a content with original filename, new link, and correct type.' do
+            subject
 
-          expect(response.body).to match '\"alt\":\"rails_sample\"'
-          expect(response.body).to match "\"url\":\"/uploads/-/system/temp"
-        end
+            expect(response.body).to match '\"alt\":\"doc_sample.txt\"'
+            expect(response.body).to match "\"url\":\"/uploads/-/system/user/#{user.id}/"
+          end
 
-        it 'does not create an Upload record' do
-          expect { subject }.not_to change { Upload.count }
-        end
-      end
+          it 'creates a corresponding Upload record' do
+            expect { subject }.to change { Upload.count }
 
-      context 'temporal with valid non-image file' do
-        subject do
-          post :create, params: { model: 'personal_snippet', file: txt }, format: :json
+            upload = Upload.last
+
+            aggregate_failures do
+              expect(upload).to exist
+              expect(upload.model).to eq user
+            end
+          end
         end
 
-        it 'returns a content with original filename, new link, and correct type.' do
-          subject
+        it 'returns 404 when given user is not the logged in one' do
+          another_user = create(:user)
 
-          expect(response.body).to match '\"alt\":\"doc_sample.txt\"'
-          expect(response.body).to match "\"url\":\"/uploads/-/system/temp"
-        end
+          post :create, params: { model: model, id: another_user.id, file: txt }, format: :json
 
-        it 'does not create an Upload record' do
-          expect { subject }.not_to change { Upload.count }
+          expect(response).to have_gitlab_http_status(404)
         end
       end
     end
diff --git a/spec/features/snippets/user_creates_snippet_spec.rb b/spec/features/snippets/user_creates_snippet_spec.rb
index 1c97d5ec5b412bd2405d89279d1942269891adfc..93d77d5b5cef4c2ae57bc9219fcacc6315754db6 100644
--- a/spec/features/snippets/user_creates_snippet_spec.rb
+++ b/spec/features/snippets/user_creates_snippet_spec.rb
@@ -41,7 +41,7 @@ describe 'User creates snippet', :js do
       expect(page).to have_content('My Snippet')
 
       link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
-      expect(link).to match(%r{/uploads/-/system/temp/\h{32}/banana_sample\.gif\z})
+      expect(link).to match(%r{/uploads/-/system/user/#{user.id}/\h{32}/banana_sample\.gif\z})
 
       reqs = inspect_requests { visit(link) }
       expect(reqs.first.status_code).to eq(200)
diff --git a/spec/routing/uploads_routing_spec.rb b/spec/routing/uploads_routing_spec.rb
index 6a041ffdd6c4bccbecb02908c950b6cf576ba735..42e84774088baad7a21fdc2cf0efdbfc4f9b60ad 100644
--- a/spec/routing/uploads_routing_spec.rb
+++ b/spec/routing/uploads_routing_spec.rb
@@ -12,10 +12,19 @@ describe 'Uploads', 'routing' do
     )
   end
 
+  it 'allows creating uploads for users' do
+    expect(post('/uploads/user?id=1')).to route_to(
+      controller: 'uploads',
+      action: 'create',
+      model: 'user',
+      id: '1'
+    )
+  end
+
   it 'does not allow creating uploads for other models' do
-    UploadsController::MODEL_CLASSES.keys.compact.each do |model|
-      next if model == 'personal_snippet'
+    unroutable_models = UploadsController::MODEL_CLASSES.keys.compact - %w(personal_snippet user)
 
+    unroutable_models.each do |model|
       expect(post("/uploads/#{model}?id=1")).not_to be_routable
     end
   end
diff --git a/spec/uploaders/file_mover_spec.rb b/spec/uploaders/file_mover_spec.rb
index e474a714b10b8e25c2c6083aa3f0c254f185ec78..d5e8a90cecd773fab5561c6db6e32c9a5e2a402d 100644
--- a/spec/uploaders/file_mover_spec.rb
+++ b/spec/uploaders/file_mover_spec.rb
@@ -3,20 +3,29 @@ require 'spec_helper'
 describe FileMover do
   include FileMoverHelpers
 
+  let(:user) { create(:user) }
   let(:filename) { 'banana_sample.gif' }
-  let(:temp_file_path) { File.join('uploads/-/system/temp', 'secret55', filename) }
+  let(:secret) { 'secret55' }
+  let(:temp_file_path) { File.join("uploads/-/system/user/#{user.id}", secret, filename) }
 
   let(:temp_description) do
     "test ![banana_sample](/#{temp_file_path}) "\
     "same ![banana_sample](/#{temp_file_path}) "
   end
-  let(:file_path) { File.join('uploads/-/system/personal_snippet', snippet.id.to_s, 'secret55', filename) }
+  let(:file_path) { File.join('uploads/-/system/personal_snippet', snippet.id.to_s, secret, filename) }
   let(:snippet) { create(:personal_snippet, description: temp_description) }
 
-  subject { described_class.new(temp_file_path, snippet).execute }
+  let(:tmp_uploader) do
+    PersonalFileUploader.new(user, secret: secret)
+  end
+
+  let(:file) { fixture_file_upload('spec/fixtures/banana_sample.gif') }
+  subject { described_class.new(temp_file_path, from_model: user, to_model: snippet).execute }
 
   describe '#execute' do
     before do
+      tmp_uploader.store!(file)
+
       expect(FileUtils).to receive(:mkdir_p).with(a_string_including(File.dirname(file_path)))
       expect(FileUtils).to receive(:move).with(a_string_including(temp_file_path), a_string_including(file_path))
       allow_any_instance_of(CarrierWave::SanitizedFile).to receive(:exists?).and_return(true)
@@ -25,6 +34,8 @@ describe FileMover do
       stub_file_mover(temp_file_path)
     end
 
+    let(:tmp_upload) { tmp_uploader.upload }
+
     context 'when move and field update successful' do
       it 'updates the description correctly' do
         subject
@@ -36,8 +47,10 @@ describe FileMover do
           )
       end
 
-      it 'creates a new update record' do
-        expect { subject }.to change { Upload.count }.by(1)
+      it 'updates existing upload record' do
+        expect { subject }
+          .to change { tmp_upload.reload.attributes.values_at('model_id', 'model_type') }
+          .from([user.id, 'User']).to([snippet.id, 'PersonalSnippet'])
       end
 
       it 'schedules a background migration' do
@@ -52,30 +65,31 @@ describe FileMover do
         expect(FileUtils).to receive(:move).with(a_string_including(file_path), a_string_including(temp_file_path))
       end
 
-      subject { described_class.new(file_path, snippet, :non_existing_field).execute }
+      subject { described_class.new(file_path, :non_existing_field, from_model: user, to_model: snippet).execute }
 
       it 'does not update the description' do
         subject
 
         expect(snippet.reload.description)
           .to eq(
-            "test ![banana_sample](/uploads/-/system/temp/secret55/banana_sample.gif) "\
-            "same ![banana_sample](/uploads/-/system/temp/secret55/banana_sample.gif) "
+            "test ![banana_sample](/uploads/-/system/user/#{user.id}/secret55/banana_sample.gif) "\
+            "same ![banana_sample](/uploads/-/system/user/#{user.id}/secret55/banana_sample.gif) "
           )
       end
 
-      it 'does not create a new update record' do
-        expect { subject }.not_to change { Upload.count }
+      it 'does not change the upload record' do
+        expect { subject }
+          .not_to change { tmp_upload.reload.attributes.values_at('model_id', 'model_type') }
       end
     end
   end
 
   context 'security' do
     context 'when relative path is involved' do
-      let(:temp_file_path) { File.join('uploads/-/system/temp', '..', 'another_subdir_of_temp') }
+      let(:temp_file_path) { File.join("uploads/-/system/user/#{user.id}", '..', 'another_subdir_of_temp') }
 
       it 'does not trigger move if path is outside designated directory' do
-        stub_file_mover('uploads/-/system/another_subdir_of_temp')
+        stub_file_mover("uploads/-/system/user/#{user.id}/another_subdir_of_temp")
         expect(FileUtils).not_to receive(:move)
 
         subject