diff --git a/chart/templates/api-deployment.yaml b/chart/templates/api-deployment.yaml
index 5870dde..ed2d2fa 100644
--- a/chart/templates/api-deployment.yaml
+++ b/chart/templates/api-deployment.yaml
@@ -15,6 +15,21 @@ spec:
       labels:
         {{- include "josie-health.componentSelectorLabels" (dict "component" .Values.api.name) | nindent 8 }}
     spec:
+      {{- if .Values.api.migration.enabled }}
+      serviceAccountName: josie-health-migrate
+      initContainers:
+        - name: trigger-migration
+          image: image-registry.openshift-image-registry.svc:5000/openshift/cli:latest
+          command:
+            - /bin/sh
+            - -c
+            - |
+              oc delete job josie-health-api-migrate --ignore-not-found
+              oc apply -f /config/migration-job.yaml
+          volumeMounts:
+            - name: migration-job
+              mountPath: /config
+      {{- end }}
       containers:
         - name: {{ .Values.api.name }}
           image: {{ include "josie-health.image" (dict "root" . "image" .Values.api.image) }}
@@ -37,4 +52,10 @@ spec:
           {{- end }}
           resources:
             {{- toYaml .Values.api.resources | nindent 12 }}
+      {{- if .Values.api.migration.enabled }}
+      volumes:
+        - name: migration-job
+          configMap:
+            name: josie-health-api-migration-job
+      {{- end }}
 {{- end }}
diff --git a/chart/values.yaml b/chart/values.yaml
index 85720d6..369a129 100644
--- a/chart/values.yaml
+++ b/chart/values.yaml
@@ -11,7 +11,7 @@ api:
   name: api
   replicas: 1
   image:
-    name: core
+    name: simple-logs-api
     tag: latest
     pullPolicy: Always
   port: 3001
@@ -34,6 +34,8 @@ api:
       insecureEdgeTerminationPolicy: Redirect
       externalCertificate:
         name: josie-health-tls
+  migration:
+    enabled: true
   redis:
     enabled: true
     name: api-redis
diff --git a/core/lib/.shards.info b/core/lib/.shards.info
index 508422a..c15d7d9 100644
--- a/core/lib/.shards.info
+++ b/core/lib/.shards.info
@@ -21,7 +21,7 @@ shards:
     version: 2.9.1
   josie-health-lut:
     git: git@github.com:josiedot/health-lut.git
-    version: 0.1.0+git.commit.ec2f4691c0be80e4a1cf47c736d11a3951f6d4b4
+    version: 0.1.0+git.commit.9c228a52489fbfe3711889b3da8447951a86f290
   josie-health-utils:
     git: git@github.com:josiedot/health-utils.git
     version: 0.1.0+git.commit.c2c3f07b62fcf4850643b346b252cd1ad8aa0094
diff --git a/core/lib/josie-health-lut/data/caffeine.json b/core/lib/josie-health-lut/data/caffeine.json
index fa4e6e8..1392625 100644
--- a/core/lib/josie-health-lut/data/caffeine.json
+++ b/core/lib/josie-health-lut/data/caffeine.json
@@ -209,6 +209,12 @@
       "default_ml": 240,
       "aliases": ["yerba", "mate", "miomio", "mio mio", "mio-mio"]
     },
+    "clubmate": {
+      "caffeine_mg": 100,
+      "category": "energy_drink",
+      "default_ml": 500,
+      "aliases": ["club-mate", "clubmatte"]
+    },
     "chai": {
       "caffeine_mg": 50,
       "category": "tea",
diff --git a/core/lib/josie-health-lut/data/drugs.json b/core/lib/josie-health-lut/data/drugs.json
index 9504124..9129afb 100644
--- a/core/lib/josie-health-lut/data/drugs.json
+++ b/core/lib/josie-health-lut/data/drugs.json
@@ -36975,5 +36975,72 @@
       ],
       "summary": "Magnesium L-threonate is a form of magnesium that crosses the blood-brain barrier, used for cognitive function."
     }
+  },
+  "kw-6356": {
+    "aliases": [
+      "kw6356",
+      "sipagladenant"
+    ],
+    "categories": [
+      "stimulant",
+      "research-chemical",
+      "eugeroic"
+    ],
+    "formatted_aftereffects": {
+      "_unit": "hours",
+      "value": "12-24"
+    },
+    "formatted_dose": {
+      "Oral": {
+        "Light": "0.5-1mg",
+        "Common": "1-2mg",
+        "Strong": "2-4mg",
+        "Heavy": "5mg+"
+      }
+    },
+    "formatted_duration": {
+      "_unit": "hours",
+      "value": "12-24"
+    },
+    "formatted_effects": [
+      "Wakefulness",
+      "Mental stimulation",
+      "Mood enhancement",
+      "Increased motivation",
+      "Dopaminergic effects",
+      "Improved focus"
+    ],
+    "formatted_onset": {
+      "_unit": "hours",
+      "value": "0.75-3"
+    },
+    "links": {
+      "pubmed": "https://pubmed.ncbi.nlm.nih.gov/36683291/",
+      "everychem": "https://everychem.com"
+    },
+    "name": "kw-6356",
+    "pretty_name": "KW-6356 (Sipagladenant)",
+    "properties": {
+      "after-effects": "12-24 hours",
+      "aliases": [
+        "kw6356",
+        "sipagladenant"
+      ],
+      "categories": [
+        "stimulant",
+        "research-chemical",
+        "eugeroic"
+      ],
+      "dose": "Oral Light: 0.5-1mg | Common: 1-2mg | Strong: 2-4mg | Heavy: 5mg+ | Clinical trials: 1-60mg single, 6-24mg daily",
+      "duration": "12-24 hours (may extend to 48hrs for sleep recovery)",
+      "half-life": "18.4-43.1 hours (mean ~22.9 hrs)",
+      "onset": "0.75-3 hours (Tmax)",
+      "receptor_dissociation": "3 hours",
+      "summary": "KW-6356 (Sipagladenant) is a highly selective adenosine A2A receptor inverse agonist. Like caffeine but ~100x more potent at A2A with insurmountable binding. Developed for Parkinson's disease (discontinued 2022). Now sold as nootropic for wakefulness and motivation. Key difference from caffeine: very long half-life (19+ hrs) and 3-hour receptor dissociation can significantly impair sleep.",
+      "mechanism": "A2A adenosine receptor inverse agonist - blocks constitutive activity and adenosine binding, releasing brake on dopaminergic/histaminergic systems",
+      "metabolism": "CYP3A4/5 and CYP2J2",
+      "warning": "Very long half-life may impair sleep up to 48hrs after dose. Use EOD or 1x/week. May cause tolerance to dopaminergic effects but not wake-promoting effects."
+    },
+    "dose_note": " Doses approximate caffeine equivalence: 100mg caffeine ≈ 0.5mg KW, 200mg ≈ 1mg, 300mg ≈ 1.5mg, 400mg ≈ 2mg. Start LOW due to long half-life."
   }
 }
\ No newline at end of file
diff --git a/core/lib/josie-health-lut/src/drug_lut.cr b/core/lib/josie-health-lut/src/drug_lut.cr
index 0dc6f6a..8faff63 100644
--- a/core/lib/josie-health-lut/src/drug_lut.cr
+++ b/core/lib/josie-health-lut/src/drug_lut.cr
@@ -176,6 +176,7 @@ module JosieHealth::LUT
       "ivermectin"                    => ["ivermectin"],
       "kanna"                         => ["kanna-tea", "kanna", "kanna100x", "kannacaffeine", "kannaciggie"],
       "kava"                          => ["kava", "kavakava", "kavalactones", "kavatea"],
+      "kw-6356"                       => ["kw6356", "sipagladenant"],
       "ketamine"                      => ["(probably s-)ket", "$k", "103mg ketamine", "50/50 ket", "50/50 mix ket", "50/50 mixed ket", "50/50mixket", "60/40 ket", "60/50 or so racemic/s ket", "70/30 mix ket", "75/25 ket", "arket", "bedtimeket", "bump ket", "ektamine", "esk", "esket.", "esket", "esketamin", "esketamine", "esketnine", "eskez", "esklet", "goodket", "groundscore-ket?", "groundscore-ketamine?", "hopefully-k..", "hopefully-k", "k 50 50", "k ektkt", "k left", "k nasal", "k question mark line", "k right", "k supplement", "k-rock", "k!", "k.", "k", "k2d3", "k374m1n3", "ka", "kalamari", "kali", "keamine", "keetyme", "ket --ago 0002", "ket + little mdma", "ket + mdma", "ket 50 mix", "ket mostly s mix", "ket new batch", "ket other batch", "ket pure batch", "ket racemic + s rests", "ket s-filtered", "ket schnozz", "ket spray", "ket-notmepersonnear", "ket!!smmam<i", "ket?", "ket", "ket/molly/unknown/thought only ket but tasted molly also", "ket&mdma", "ket+4mmc", "ket+mdma", "ket+methadone", "ket+molly", "keta", "ketal", "ketalar", "ketaline", "ketam", "ketam9ne", "ketaman", "ketamanjaro", "ketamean", "ketami e", "ketami", "ketamie", "ketamië", "ketamien", "ketamiine", "ketamime", "ketamin", "ketamina", "ketamine idk", "ketamine insufflated", "ketamine oral", "ketamine pile", "ketamine-mixed", "ketamine-rests", "ketamine!", "ketamine?", "ketamine.exe", "ketamine", "ketaminë", "ketamïne", "ķêťãmïñĕ", "ketamine('more s' batch)", "ketamine(a)", "ketamine(b)", "ketamine(gn8-line)", "ketamine&salt", "ketamine&speed", "ketamine+", "ketaminea", "ketamineinthechillah", "ketaminenewsbatch", "ketamineq", "ketaminə", "ketaminoo", "ketamins", "ketaminw", "ketamoné", "ketfast", "ketline", "ketmaine", "ketmainewea", "ketman", "ketmin", "ketmine", "kets", "ketspray", "kett", "ketttimeme", "kettymean", "ketwoman", "kety", "keyamin", "kez", "kittey", "kitty", "kt", "letamine", "line ket", "line ketamine", "lines k", "mixed batches esket", "mixed k", "mixed ket", "mixed rests k", "mixed-ket", "mixrests-ket", "new batch esket", "new batch ket", "new batch s-ketamine", "night line of ketamine", "of ket", "of ketamine", "ofket", "orange-ket", "other batch ket", "pure ket & off sleep", "pure ket batch", "r-ketamine", "racemic ket", "racemic/arket-mix", "racemicket", "rbatch ket", "specialk", "substance k", "timelock-k", "unknown ket from box", "unknown ket"],
       "kratom"                        => ["greenkratom.md", "greenkratom", "greenmd", "greenmdkratom", "krarom", "kratom borneo", "kratom maeng da", "kratom_white_thai", "kratom_xanax_adderall", "kratom-8%-fse", "kratom", "kratom+kava", "kratom+matcha", "kratomblend", "kratome", "kratomshake", "lightpouryellowsumatra_redmd", "red_maeng_da_kratom", "red+green+yellowkratom", "redk+s&vblend", "redkasung", "redkasung/stem&vein", "redkasung+gmd+s&v", "redkasung+s/v", "redkasung+s&v", "redkasung+yellowsumatra", "redkasyng", "redkratom", "redmaengda", "redmd", "redmd+s&v", "redmd+yellowsumatra", "redmdshake", "redmdyellowsumatra", "redmdyellowsumatragreenthai", "stim-kratom", "white-indo-kratom", "y.sumatra/r.maengda", "yellokratom", "yellowkratom oral", "yellowkratom", "yellowkratomnotmydose", "yellowkraton", "yellowsumatra.redmd", "yellowsumatra", "yellowsumatra+redmd", "yellowsumatraredmd"],
       "lactase"                       => ["lactrase", "laktase"],
diff --git a/core/shard.lock b/core/shard.lock
index ba073cd..59484b4 100644
--- a/core/shard.lock
+++ b/core/shard.lock
@@ -10,7 +10,7 @@ shards:
 
   josie-health-lut:
     git: git@github.com:josiedot/health-lut.git
-    version: 0.1.0+git.commit.ec2f4691c0be80e4a1cf47c736d11a3951f6d4b4
+    version: 0.1.0+git.commit.9c228a52489fbfe3711889b3da8447951a86f290
 
   josie-health-utils:
     git: git@github.com:josiedot/health-utils.git
diff --git a/core/src/csv_parser.cr b/core/src/csv_parser.cr
index 697f68b..fb4e8a6 100644
--- a/core/src/csv_parser.cr
+++ b/core/src/csv_parser.cr
@@ -25,13 +25,20 @@ module CsvParser
     parts
   end
 
+  FORMULA_PREFIXES = {'=', '+', '-', '@', '\t', '\r'}
+
   # Escape a value for CSV output
   # Wraps in quotes if contains comma, quote, or newline
+  # Prefixes formula-starting characters with ' to prevent injection
   def self.escape(value : String) : String
-    if value.includes?(",") || value.includes?("\"") || value.includes?("\n")
-      "\"#{value.gsub("\"", "\"\"")}\""
+    sanitized = value
+    if sanitized.size > 0 && FORMULA_PREFIXES.includes?(sanitized[0])
+      sanitized = "'" + sanitized
+    end
+    if sanitized.includes?(",") || sanitized.includes?("\"") || sanitized.includes?("\n")
+      "\"#{sanitized.gsub("\"", "\"\"")}\""
     else
-      value
+      sanitized
     end
   end
 end
diff --git a/core/src/handlers/auth_handler.cr b/core/src/handlers/auth_handler.cr
index af517c0..7bc4d00 100644
--- a/core/src/handlers/auth_handler.cr
+++ b/core/src/handlers/auth_handler.cr
@@ -558,6 +558,12 @@ class AuthHandler
       return
     end
 
+    if authenticated_user && authenticated_user != primary_user
+      response.status_code = 403
+      response.print({"error" => "Forbidden: you can only link accounts to your own"}.to_json)
+      return
+    end
+
     if primary_user == secondary_user
       response.status_code = 400
       response.print({"error" => "Cannot link account to itself"}.to_json)
diff --git a/core/src/handlers/log_handler.cr b/core/src/handlers/log_handler.cr
index c1fcb2b..0e64ceb 100644
--- a/core/src/handlers/log_handler.cr
+++ b/core/src/handlers/log_handler.cr
@@ -10,15 +10,42 @@ require "../link_builder"
 class LogHandler
   Log = JosieHealth::Utils::Log
 
+  @authenticated_user : String?
+
   # Max input lengths to prevent memory exhaustion
   MAX_SUBSTANCE_LENGTH  = 100
   MAX_DOSAGE_LENGTH     =  50
   MAX_ANNOTATION_LENGTH = 500
   MAX_RAW_DOSE_LENGTH   = 500
 
+  MAX_FUTURE_MINUTES = 5
+  MAX_PAST_YEARS     = 10
+
   def initialize(@redis_client : RedisClient)
   end
 
+  private def validate_timestamp(timestamp : String, response : HTTP::Server::Response) : Bool
+    begin
+      ts = Time.parse_rfc3339(timestamp)
+      now = Time.utc
+      if ts > now + MAX_FUTURE_MINUTES.minutes
+        response.status_code = 400
+        response.print({"error" => "Timestamp cannot be more than #{MAX_FUTURE_MINUTES} minutes in the future"}.to_json)
+        return false
+      end
+      if ts < now - MAX_PAST_YEARS.years
+        response.status_code = 400
+        response.print({"error" => "Timestamp cannot be more than #{MAX_PAST_YEARS} years in the past"}.to_json)
+        return false
+      end
+      true
+    rescue
+      response.status_code = 400
+      response.print({"error" => "Invalid timestamp format, expected RFC3339"}.to_json)
+      false
+    end
+  end
+
   # Format dosage for display - removes .0 from whole numbers
   # 95.0 -> "95", 95.5 -> "95.5", 0.5 -> "0.5"
   private def format_dosage(value) : String
@@ -68,7 +95,19 @@ class LogHandler
     end
   end
 
-  def handle(request : HTTP::Request, response : HTTP::Server::Response)
+  private def authorize_user(user_id : String, authenticated_user : String?, response : HTTP::Server::Response) : Bool
+    if auth_user = authenticated_user
+      if auth_user != user_id
+        response.status_code = 403
+        response.print({"error" => "Forbidden: you can only access your own data"}.to_json)
+        return false
+      end
+    end
+    true
+  end
+
+  def handle(request : HTTP::Request, response : HTTP::Server::Response, authenticated_user : String? = nil)
+    @authenticated_user = authenticated_user
     Log.debug("log_handler", "Handling #{request.method} #{request.path}")
     case request.path
     when "/v1/logs/substances/undose"
@@ -234,7 +273,9 @@ class LogHandler
 
       # Extract common fields
       timestamp = data["timestamp"]?.try(&.as_s) || Time.utc.to_rfc3339
+      return unless validate_timestamp(timestamp, response)
       user_id = data["user_id"].as_s
+      return unless authorize_user(user_id, @authenticated_user, response)
       # For raw_dose mode, unit is already embedded in dosage string
       unit = raw_dose_mode ? "" : (data["unit"]?.try(&.as_s) || "mg")
       source = data["source"]?.try(&.as_s) || "api"
@@ -440,6 +481,7 @@ class LogHandler
         response.print({"error" => "Missing required fields: user_id and annotation"}.to_json)
         return
       end
+      return unless authorize_user(user_id, @authenticated_user, response)
 
       # Update the annotation on the last dose
       success = @redis_client.update_last_dose_annotation(user_id, note_text)
@@ -476,7 +518,9 @@ class LogHandler
 
       # Extract data
       timestamp = data["timestamp"]?.try(&.as_s) || Time.utc.to_rfc3339
+      return unless validate_timestamp(timestamp, response)
       user_id = data["user_id"]?.try(&.as_s) || "unknown"
+      return unless authorize_user(user_id, @authenticated_user, response)
       pulse = data["pulse"]?.try(&.as_i)
       systolic_bp = data["systolic_bp"]?.try(&.as_i)
       diastolic_bp = data["diastolic_bp"]?.try(&.as_i)
@@ -542,7 +586,9 @@ class LogHandler
 
       # Extract data
       timestamp = data["timestamp"]?.try(&.as_s) || Time.utc.to_rfc3339
+      return unless validate_timestamp(timestamp, response)
       user_id = data["user_id"]?.try(&.as_s) || "unknown"
+      return unless authorize_user(user_id, @authenticated_user, response)
       test_type = data["test_type"].as_s.downcase
       value = data["value"].to_s
       unit = data["unit"]?.try(&.as_s) || ""
@@ -687,7 +733,7 @@ class LogHandler
       user_id = request.query_params["user_id"]?
 
       if user_id
-        # Get vitals for specific user
+        return unless authorize_user(user_id, @authenticated_user, response)
         all_vitals = @redis_client.get_user_vitals(user_id, limit + offset)
       else
         response.status_code = 400
@@ -738,7 +784,7 @@ class LogHandler
       test_type = request.query_params["test_type"]?
 
       if user_id
-        # Get labs for specific user
+        return unless authorize_user(user_id, @authenticated_user, response)
         all_labs = @redis_client.get_user_labs(user_id, limit + offset)
       else
         response.status_code = 400
@@ -791,6 +837,9 @@ class LogHandler
     begin
       # Parse query parameters
       user_id = request.query_params["user_id"]?
+      if user_id
+        return unless authorize_user(user_id, @authenticated_user, response)
+      end
       include_substances = request.query_params["include_substances"]? != "false"
 
       csv_output = String.build do |str|
@@ -846,6 +895,7 @@ class LogHandler
         response.print({"error" => "Missing user_id query parameter"}.to_json)
         return
       end
+      return unless authorize_user(user_id, @authenticated_user, response)
 
       lines = body.split('\n').reject(&.empty?)
 
@@ -1197,6 +1247,7 @@ class LogHandler
         response.print({"error" => "Missing required parameter: user_id"}.to_json)
         return
       end
+      return unless authorize_user(user_id, @authenticated_user, response)
 
       dose = @redis_client.get_dose(user_id, timestamp)
       if dose
@@ -1229,6 +1280,7 @@ class LogHandler
         response.print({"error" => "Missing required field: user_id"}.to_json)
         return
       end
+      return unless authorize_user(user_id, @authenticated_user, response)
 
       # Get existing dose to validate ownership
       existing = @redis_client.get_dose(user_id, timestamp)
@@ -1318,6 +1370,7 @@ class LogHandler
         response.print({"error" => "Missing required field: user_id"}.to_json)
         return
       end
+      return unless authorize_user(user_id, @authenticated_user, response)
 
       success = @redis_client.delete_dose(user_id, timestamp)
       if success
@@ -1358,6 +1411,7 @@ class LogHandler
       end
 
       user_id = data["user_id"].as_s
+      return unless authorize_user(user_id, @authenticated_user, response)
 
       # Remove the last dose from Redis
       success = @redis_client.remove_last_dose(user_id)
diff --git a/core/src/handlers/metric_handler.cr b/core/src/handlers/metric_handler.cr
index 663c645..855bb5f 100644
--- a/core/src/handlers/metric_handler.cr
+++ b/core/src/handlers/metric_handler.cr
@@ -3,11 +3,13 @@ require "josie-health-lut"
 require "josie-health-utils"
 require "digest/sha256"
 require "http/client"
+require "socket"
 require "../redis_client"
 require "../range_parser"
 
 class MetricHandler
   Log = JosieHealth::Utils::Log
+  @authenticated_user : String?
   # Wrapped service URL (legacy Ruby service for video generation)
   WRAPPED_SERVICE_URL = ENV["WRAPPED_SERVICE_URL"]? || "http://wrapped:3002"
   # Wrapped-cr service URL (Crystal service for image generation)
@@ -132,7 +134,66 @@ class MetricHandler
     end
   end
 
-  def handle(request : HTTP::Request, response : HTTP::Server::Response)
+  private def authorize_user(user_id : String, response : HTTP::Server::Response) : Bool
+    if auth_user = @authenticated_user
+      if auth_user != user_id
+        response.status_code = 403
+        response.print({"error" => "Forbidden: you can only access your own data"}.to_json)
+        return false
+      end
+    end
+    true
+  end
+
+  ALLOWED_CALLBACK_HOSTS = ENV["ALLOWED_CALLBACK_HOSTS"]?.try(&.split(",").map(&.strip)) || ["discord-bot", "localhost"]
+
+  private def validate_callback_url(url : String) : Bool
+    begin
+      uri = URI.parse(url)
+      host = uri.host
+      return false unless host
+
+      # Block non-HTTP schemes
+      return false unless uri.scheme == "http" || uri.scheme == "https"
+
+      # Allow configured internal service hosts
+      return true if ALLOWED_CALLBACK_HOSTS.includes?(host)
+
+      # Resolve hostname and block private IP ranges
+      begin
+        addrs = Socket::Addrinfo.resolve(host, uri.port || 80, type: Socket::Type::STREAM)
+        addrs.each do |addr|
+          ip_str = addr.ip_address.address
+          return false if private_ip?(ip_str)
+        end
+      rescue
+        return false
+      end
+
+      true
+    rescue
+      false
+    end
+  end
+
+  private def private_ip?(ip : String) : Bool
+    parts = ip.split(".")
+    return true if parts.size != 4
+
+    a = parts[0].to_i?
+    b = parts[1].to_i?
+    return true unless a && b
+
+    a == 127 ||                          # 127.0.0.0/8
+    a == 10 ||                           # 10.0.0.0/8
+    (a == 172 && b >= 16 && b <= 31) ||  # 172.16.0.0/12
+    (a == 192 && b == 168) ||            # 192.168.0.0/16
+    (a == 169 && b == 254) ||            # 169.254.0.0/16
+    a == 0                               # 0.0.0.0/8
+  end
+
+  def handle(request : HTTP::Request, response : HTTP::Server::Response, authenticated_user : String? = nil)
+    @authenticated_user = authenticated_user
     case request.path
     when "/v1/metrics/substances"
       handle_substance_metrics(response, nil)
@@ -417,6 +478,7 @@ class MetricHandler
     end
 
     user_id = URI.decode_www_form(path_parts[4])
+    return unless authorize_user(user_id, response)
     endpoint = path_parts[5]
     Log.debug("metric", "Routing to endpoint: #{endpoint}")
 
@@ -1229,6 +1291,7 @@ class MetricHandler
     end
 
     user_id = URI.decode_www_form(path_parts[4])
+    return unless authorize_user(user_id, response)
     year_or_image = path_parts[5]?
     image_flag = path_parts[6]?
 
@@ -1496,6 +1559,7 @@ class MetricHandler
     end
 
     user_id = URI.decode_www_form(path_parts[4])
+    return unless authorize_user(user_id, response)
     year_str = path_parts[5]?
     year = year_str.try(&.to_i?) || Time.utc.year
 
@@ -1522,6 +1586,12 @@ class MetricHandler
         return
       end
 
+      unless validate_callback_url(callback_url)
+        response.status_code = 400
+        response.print({"error" => "Invalid callback URL: must be HTTP/HTTPS and not target private networks"}.to_json)
+        return
+      end
+
       # Check if user already has an active video generation
       if @redis_client.is_vwrapped_active(user_id)
         response.status_code = 429
@@ -1688,6 +1758,12 @@ class MetricHandler
         return
       end
 
+      unless validate_callback_url(callback_url)
+        response.status_code = 400
+        response.print({"error" => "Invalid callback URL: must be HTTP/HTTPS and not target private networks"}.to_json)
+        return
+      end
+
       # Determine if this is an image request
       is_image_request = (year_or_image == "image") || (image_flag == "image")
 
diff --git a/core/src/main.cr b/core/src/main.cr
index eb03ce1..f797a81 100644
--- a/core/src/main.cr
+++ b/core/src/main.cr
@@ -2,15 +2,13 @@ require "http/server"
 require "josie-health-utils"
 require "./redis_client"
 require "./router"
+require "./migrator"
 
-# Josie Health Core API
 class JosieHealthCore
   Log = JosieHealth::Utils::Log
 
   def initialize(@port : Int32 = 3001)
-    # Initialize Redis client
     @redis_client = RedisClient.new
-
     @router = APIRouter.new(@redis_client)
   end
 
@@ -26,5 +24,11 @@ class JosieHealthCore
   end
 end
 
-# Start the server
-JosieHealthCore.new.start
+case ARGV[0]?
+when "migrate"
+  redis_client = RedisClient.new
+  migrator = Migrator.new(redis_client)
+  exit migrator.run
+else
+  JosieHealthCore.new.start
+end
diff --git a/core/src/router.cr b/core/src/router.cr
index 46624fc..9c82a40 100644
--- a/core/src/router.cr
+++ b/core/src/router.cr
@@ -2,6 +2,7 @@ require "http/server"
 require "json"
 require "base64"
 require "openssl"
+require "crypto/subtle"
 require "josie-health-utils"
 require "./handlers/log_handler"
 require "./handlers/metric_handler"
@@ -40,8 +41,8 @@ class APIRouter
 
   def initialize(redis_client : RedisClient)
     @redis_client = redis_client
-    @auth_username = ENV["API_USERNAME"]? || "admin"
-    @auth_password = ENV["API_PASSWORD"]? || "admin"
+    @auth_username = ENV["API_USERNAME"]? || raise "API_USERNAME environment variable is required"
+    @auth_password = ENV["API_PASSWORD"]? || raise "API_PASSWORD environment variable is required"
 
     @log_handler = LogHandler.new(redis_client)
     @metric_handler = MetricHandler.new(redis_client)
@@ -59,9 +60,20 @@ class APIRouter
       request = context.request
       response = context.response
 
-      # Add CORS headers
-      response.headers["Access-Control-Allow-Origin"] = "*"
-      response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
+      # Security headers
+      response.headers["X-Frame-Options"] = "DENY"
+      response.headers["X-Content-Type-Options"] = "nosniff"
+      response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
+      response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
+
+      # CORS headers
+      origin = request.headers["Origin"]?
+      allowed_origins = ["https://josie.health", "https://api.josie.health", "https://heartbeat.josie.health"]
+      if origin && allowed_origins.includes?(origin)
+        response.headers["Access-Control-Allow-Origin"] = origin
+        response.headers["Vary"] = "Origin"
+      end
+      response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
       response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
 
       if request.method == "OPTIONS"
@@ -133,11 +145,11 @@ class APIRouter
         # Route to appropriate handlers
       when .starts_with?("/v1/logs")
         Log.debug("router", "Routing to LogHandler")
-        @log_handler.handle(request, response)
+        @log_handler.handle(request, response, @authenticated_user)
       when .starts_with?("/v1/analytics/")
         @metric_handler.handle_analytics(request, response)
       when .starts_with?("/v1/metrics")
-        @metric_handler.handle(request, response)
+        @metric_handler.handle(request, response, @authenticated_user)
       when "/v1/substances"
         if request.method == "GET"
           @substance_handler.handle_list_substances(request, response)
@@ -220,7 +232,8 @@ class APIRouter
       decoded = Base64.decode_string(encoded)
       username, password = decoded.split(':', 2)
 
-      if username == @auth_username && password == @auth_password
+      if Crypto::Subtle.constant_time_compare(username, @auth_username) &&
+         Crypto::Subtle.constant_time_compare(password, @auth_password)
         # Basic auth doesn't set authenticated_user (admin access)
         return true
       end
@@ -338,10 +351,22 @@ class APIRouter
     end
   end
 
+  private def authorize_user(user_id : String, response : HTTP::Server::Response) : Bool
+    if auth_user = @authenticated_user
+      if auth_user != user_id
+        response.status_code = 403
+        response.print({"error" => "Forbidden: you can only access your own data"}.to_json)
+        return false
+      end
+    end
+    true
+  end
+
   private def handle_user_routes(request : HTTP::Request, response : HTTP::Server::Response)
     # /v1/users/{user_id}/timezone
     if match = request.path.match(/\/v1\/users\/([^\/]+)\/timezone$/)
       user_id = URI.decode_www_form(match[1])
+      return unless authorize_user(user_id, response)
 
       case request.method
       when "GET"
@@ -378,6 +403,7 @@ class APIRouter
       # /v1/users/{user_id}/data - GET stats or DELETE all user data
     elsif match = request.path.match(/\/v1\/users\/([^\/]+)\/data$/)
       user_id = URI.decode_www_form(match[1])
+      return unless authorize_user(user_id, response)
 
       case request.method
       when "GET"
diff --git a/discord-bot/Dockerfile b/discord-bot/Dockerfile
index 847cd5d..674b0db 100644
--- a/discord-bot/Dockerfile
+++ b/discord-bot/Dockerfile
@@ -6,7 +6,7 @@ COPY . .
 RUN test -f .ssh/ssh-privatekey && cp .ssh/ssh-privatekey ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa || true
 RUN shards install && crystal build src/annotate_bot.cr --release --static -o annotate
 
-FROM alpine:latest
+FROM alpine:3.21
 RUN apk --no-cache add ca-certificates
 WORKDIR /app
 COPY --from=builder /app/annotate .
diff --git a/discord-bot/src/webhook_server.cr b/discord-bot/src/webhook_server.cr
index f9a83d1..0efc381 100644
--- a/discord-bot/src/webhook_server.cr
+++ b/discord-bot/src/webhook_server.cr
@@ -9,6 +9,7 @@ class WebhookServer
   @server : HTTP::Server
   @discord_client : Discord::Client
   @pending_requests : Hash(String, PendingRequest)
+  @pending_mutex : Mutex
 
   struct PendingRequest
     property channel_id : Discord::Snowflake
@@ -23,6 +24,7 @@ class WebhookServer
 
   def initialize(@discord_client : Discord::Client, port : Int32 = 8080)
     @pending_requests = Hash(String, PendingRequest).new
+    @pending_mutex = Mutex.new
 
     @server = HTTP::Server.new do |context|
       handle_webhook(context)
@@ -48,7 +50,9 @@ class WebhookServer
   end
 
   def register_pending_request(request_id : String, channel_id : Discord::Snowflake, user_id : String, request_type : String)
-    @pending_requests[request_id] = PendingRequest.new(channel_id, user_id, request_type)
+    @pending_mutex.synchronize do
+      @pending_requests[request_id] = PendingRequest.new(channel_id, user_id, request_type)
+    end
     puts "Registered pending request: #{request_id} for #{user_id} in channel #{channel_id}"
   end
 
@@ -126,7 +130,11 @@ class WebhookServer
     begin
       callback = DTO::WebhookWrappedComplete.from_json(body)
 
-      pending_request = @pending_requests[callback.request_id]?
+      pending_request = @pending_mutex.synchronize do
+        req = @pending_requests[callback.request_id]?
+        @pending_requests.delete(callback.request_id) if req
+        req
+      end
       unless pending_request
         puts "Received callback for unknown request_id: #{callback.request_id}"
         response.status_code = 404
@@ -134,8 +142,6 @@ class WebhookServer
         return
       end
 
-      @pending_requests.delete(callback.request_id)
-
       case callback.status
       when "success"
         handle_success_callback(callback, pending_request)
@@ -228,15 +234,16 @@ class WebhookServer
     current_time = Time.utc
     expired_keys = [] of String
 
-    @pending_requests.each do |request_id, pending_request|
-      # Remove requests older than 30 minutes
-      if (current_time - pending_request.created_at).total_minutes > 30
-        expired_keys << request_id
+    @pending_mutex.synchronize do
+      @pending_requests.each do |request_id, pending_request|
+        if (current_time - pending_request.created_at).total_minutes > 30
+          expired_keys << request_id
+        end
       end
-    end
 
-    expired_keys.each do |key|
-      @pending_requests.delete(key)
+      expired_keys.each do |key|
+        @pending_requests.delete(key)
+      end
     end
 
     if expired_keys.any?
diff --git a/irc-bot/Dockerfile b/irc-bot/Dockerfile
index f064f9f..ecc5b5e 100644
--- a/irc-bot/Dockerfile
+++ b/irc-bot/Dockerfile
@@ -4,7 +4,7 @@ RUN apk add --no-cache libssh2-dev libssh2-static openssl-dev openssl-libs-stati
 COPY . .
 RUN crystal build src/irc_bot.cr --release --static -o irc-bot
 
-FROM docker.io/alpine:latest
+FROM docker.io/alpine:3.21
 RUN apk --no-cache add ca-certificates
 WORKDIR /app
 COPY --from=builder /app/irc-bot .
diff --git a/irc-ingestion/Dockerfile b/irc-ingestion/Dockerfile
index 158a371..8aaeab2 100644
--- a/irc-ingestion/Dockerfile
+++ b/irc-ingestion/Dockerfile
@@ -11,7 +11,7 @@ RUN apk add --no-cache libssh2-dev libssh2-static openssl-dev openssl-libs-stati
     rm -rf ~/.ssh .ssh
 RUN shards build --release --static
 
-FROM alpine:latest
+FROM alpine:3.21
 RUN apk --no-cache add ca-certificates
 WORKDIR /app
 COPY --from=builder /app/bin/josie-health-irc .
