diff --git a/back/Containerfile.larvatagger b/back/Containerfile.larvatagger
index 48fd0592ce9fd716eabcb25b45e4b586ce49a813..bc9e4afd5715d7fce6187cc49fe3bc5070761454 100644
--- a/back/Containerfile.larvatagger
+++ b/back/Containerfile.larvatagger
@@ -52,9 +52,6 @@ RUN cd "${JULIA_PROJECT}" \
 
 # final stage
 
-ARG LARVATAGGER_PORT=9286
-ENV LARVATAGGER_PORT=${LARVATAGGER_PORT}
-
 COPY --chown=julia larvatagger-entrypoint.sh /app/
 
 ENTRYPOINT ["/app/larvatagger-entrypoint.sh"]
diff --git a/back/larvatagger-entrypoint.sh b/back/larvatagger-entrypoint.sh
index 70a99f84c5bab0e925e0be9b56b6dbeca364515a..aa3148262e7ecbd5645582d987dbc231d0ad3486 100755
--- a/back/larvatagger-entrypoint.sh
+++ b/back/larvatagger-entrypoint.sh
@@ -2,6 +2,14 @@
 
 if [ -z "$LARVATAGGER_PORT" ]; then
   LARVATAGGER_PORT=9286
+else
+  echo "Using environment variable: LARVATAGGER_PORT= $LARVATAGGER_PORT"
 fi
 
-julia -e "using LarvaTagger.REST.Server; run_backend(\"/app\"; host=\"0.0.0.0\", port=$LARVATAGGER_PORT)"
+if [ -z "$LARVATAGGER_TOKEN_EXPIRY" ]; then
+  LARVATAGGER_TOKEN_EXPIRY=14400
+else
+  echo "Using environment variable: LARVATAGGER_TOKEN_EXPIRY= $LARVATAGGER_TOKEN_EXPIRY"
+fi
+
+julia -e "using LarvaTagger.REST.Server; run_backend(\"/app\", $LARVATAGGER_TOKEN_EXPIRY; host=\"0.0.0.0\", port=$LARVATAGGER_PORT)"
diff --git a/back/larvatagger-no-build.sh b/back/larvatagger-no-build.sh
index f505281ad3213e379d7f7d1c6d564d019f39f286..7101dd78a518d69cc5900982cd093c28b29184bc 100755
--- a/back/larvatagger-no-build.sh
+++ b/back/larvatagger-no-build.sh
@@ -4,13 +4,22 @@
 
 if [ -z "$LARVATAGGER_PORT" ]; then
   LARVATAGGER_PORT=9286
+else
+  echo "Using environment variable: LARVATAGGER_PORT= $LARVATAGGER_PORT"
 fi
 if [ -z "$LARVATAGGER_IMAGE" ]; then
   LARVATAGGER_IMAGE=docker.io/flaur/larvatagger:0.20-bigfat
+else
+  echo "Using environment variable: LARVATAGGER_IMAGE= $LARVATAGGER_IMAGE"
+fi
+if [ -z "$LARVATAGGER_TOKEN_EXPIRY" ]; then
+  LARVATAGGER_TOKEN_EXPIRY=3600
+else
+  echo "Using environment variable: LARVATAGGER_TOKEN_EXPIRY= $LARVATAGGER_TOKEN_EXPIRY"
 fi
 
-podman run -d -p $LARVATAGGER_PORT:9285 --entrypoint=julia $LARVATAGGER_IMAGE \
-  --project=/app -e 'using LarvaTagger.REST.Server; run_backend("/app"; host="0.0.0.0")'
+podman run -d -p $LARVATAGGER_PORT:9285 --entrypoint=julia $LARVATAGGER_IMAGE --project=/app \
+  -e "using LarvaTagger.REST.Server; run_backend(\"/app\", $LARVATAGGER_TOKEN_EXPIRY; host=\"0.0.0.0\")"
 
 CONTAINER=`podman ps | grep $LARVATAGGER_IMAGE | cut -d' ' -f1`
 if ! [ -z "$CONTAINER" ]; then
diff --git a/src/GenieExtras.jl b/src/GenieExtras.jl
index 40cf48a4fa308ea6ab9a3b9c735bfdc133852c19..ebe416c469fe35307a2cbe5faa8386b83008bf6e 100644
--- a/src/GenieExtras.jl
+++ b/src/GenieExtras.jl
@@ -1,8 +1,11 @@
 module GenieExtras
 
 import Genie.Renderer.Html
+import GenieSession: GenieSession, Session
+using NyxWidgets.Base: Cache
 
-export publish_css, appinfo, add_footer
+export publish_css, appinfo, add_footer, Session, SessionRegistry, getsessionid, getsession,
+       clearexpired
 
 const project_root = dirname(Base.current_project())
 
@@ -49,4 +52,129 @@ add_footer(ui::AbstractString, footer=footer) = "$ui$(footer())"
 
 add_footer(ui::Function, footer=footer) = () -> add_footer(ui(), footer)
 
+struct SessionRegistry
+    register
+    idlength
+    purgesession
+    purgesession_args
+end
+
+function SessionRegistry(f, args=(:session_id,); idlength=32)
+    SessionRegistry(Cache{String, Dict{Symbol, Any}}(), idlength, f, args)
+end
+
+Base.lock(f::Function, registry::SessionRegistry) = lock(f, registry.register)
+
+function check_session_id_length(registry, session_id)
+    if !isnothing(registry.idlength) && length(session_id) < registry.idlength
+        throw("wrong session_id length")
+    end
+end
+
+function Base.haskey(registry::SessionRegistry, session_id::AbstractString)
+    lock(registry) do
+        for session_long_id in keys(registry.register.cache)
+            if startswith(session_long_id, session_id)
+                return true
+            end
+        end
+        return false
+    end
+end
+
+function Base.getindex(registry::SessionRegistry, session_id::AbstractString)
+    check_session_id_length(registry, session_id)
+    lock(registry) do
+        for (session_long_id, records) in pairs(registry.register.cache)
+            if startswith(session_long_id, session_id)
+                records[:lastseen] = time()
+                return records
+            end
+        end
+        throw(KeyError(session_id))
+    end
+end
+
+Base.getindex(registry::SessionRegistry, session_id::AbstractString, record::Symbol
+             ) = registry[session_id][record]
+
+function getsessionid(session_long_id::String, idlength::Union{Nothing, <:Integer})
+    session_id = session_long_id
+    if !isnothing(idlength)
+        session_id = string(session_id[1:idlength])
+    end
+    return session_id
+end
+
+function getsessionid(registry::SessionRegistry)
+    session = GenieSession.session()
+    session_long_id = session.id
+    session_id = getsessionid(session_long_id, registry.idlength)
+    # create the entry in the register if missing
+    records = lock(registry) do
+        register = registry.register.cache
+        if session_id != session_long_id && session_id in keys(register)
+            # if the session has been registered with its short id (and `getsession`),
+            # update the short id with the long one
+            register[session_long_id] = pop!(register, session_id)
+        else
+            # registry.register is a NyxWidgets.Cache and `getindex` behaves like `get!`
+            registry.register[session_long_id]
+        end
+    end
+    # register minimum information
+    records[:session] = session
+    records[:lastseen] = time()
+    # run maintenance routine
+    clearexpired(registry)
+    return session_id
+end
+
+function getsession(registry::SessionRegistry, session_id::AbstractString)
+    if haskey(registry, session_id) # check for long id
+        registry[session_id]
+    else # call get! on short id (if session_id is short); not ideal
+        registry.register[session_id]
+    end
+end
+
+function clearexpired(registry, expiry::Real=604800)
+    lock(registry) do
+        register = registry.register.cache
+        for (session_long_id, records) in pairs(register)
+            if !haskey(records, :lastseen)
+                @warn "Missing key: lastseen"
+                continue
+            end
+            expiry <= time() - records[:lastseen] || continue
+            # prepare to clear the session; collect the arguments to purgesession
+            session_id = getsessionid(session_long_id, registry.idlength)
+            available_args = Dict(:session_id=>session_id,
+                                  :session_long_id=>session_long_id,
+                                  records...)
+            @info "Purging session" session_id
+            pop!(register, session_long_id)
+            if haskey(records, :session)
+                session = records[:session]
+                # see learn.genieframework.com/framework/genie.jl/recipes/session
+                empty!(session.data)
+            else
+                @warn "Missing key: session"
+            end
+            #
+            function get′(arg)
+                get(available_args, arg) do
+                    @warn "Missing argument for purgesession" arg
+                    nothing
+                end
+            end
+            try
+                registry.purgesession(get′.(registry.purgesession_args)...)
+            catch
+                @error "\`purgesession\` callback failed"
+            end
+        end
+    end
+end
+
 end
diff --git a/src/apps/larvatagger/app.jl b/src/apps/larvatagger/app.jl
index b02e1aa44276703051166a5173ef992c17936306..7b3081623789d4afc10123143615d6dcce0ebebe 100644
--- a/src/apps/larvatagger/app.jl
+++ b/src/apps/larvatagger/app.jl
@@ -3,7 +3,7 @@ module LarvaTagger
 using NyxUI, NyxUI.Storage, NyxUI.GenieExtras
 using NyxWidgets.Base: Cache
 import LarvaTagger as LT
-using GenieSession, Stipple
+using Stipple
 import Stipple: @app, @init, @private, @in, @onchange, @onbutton, @click
 import StippleUI: tooltip
 
@@ -19,24 +19,30 @@ mutable struct Model
     kwargs
 end
 
-const bonito_models = Cache{String, Model}()
+function purgesession(model, session_id)
+    if isnothing(model)
+        @warn "Session data prematurely lost"
+    elseif !isnothing(model.app)
+        close(model.app)
+    end
+    purge_appdata(session_id, "larvatagger")
+    BonitoServer.addsession(bonito_app, session_id; restart=true)
+end
+
+const session_registry = SessionRegistry(purgesession, (:model, :session_id))
 
 const bonito_app = NamedApp("larvatagger",
     function (session)
         bucket = getbucket(session, "larvatagger", :read)
         mkpath(bucket)
-        model = lock(bonito_models) do
-            if haskey(bonito_models, session)
-                bonito_models.cache[session]
-            else
-                kwargs = Dict{Symbol, Any}()
-                if haskey(ENV, "LARVATAGGER_BACKEND")
-                    kwargs[:backend_directory] = backend = ENV["LARVATAGGER_BACKEND"]
-                    @info "Using environment variable" LARVATAGGER_BACKEND=backend
-                end
-                inputfile = Ref{Union{Nothing, String}}(nothing)
-                bonito_models.cache[session] = Model(1.0, nothing, inputfile, kwargs)
+        model = get!(getsession(session_registry, session), :model) do
+            kwargs = Dict{Symbol, Any}()
+            if !isempty(get(ENV, "LARVATAGGER_BACKEND", ""))
+                kwargs[:backend_directory] = backend = ENV["LARVATAGGER_BACKEND"]
+                @info "Using environment variable" LARVATAGGER_BACKEND=backend
             end
+            inputfile = Ref{Union{Nothing, String}}(nothing)
+            Model(1.0, nothing, inputfile, kwargs)
         end
         exportdir = joinpath("public", session, "larvatagger")
         function prepare_download(srcfile)
@@ -66,13 +72,9 @@ const bonito_app = NamedApp("larvatagger",
     end
 )
 
-function purgesession(session)
-    model = lock(bonito_models) do
-        pop!(bonito_models.cache, session)
-    end
-    isnothing(model.app) || close(model.app)
-    purge_appdata(session, "larvatagger")
-    BonitoServer.addsession(bonito_app, session; restart=true)
+function purgesession(session_id)
+    model = pop!(session_registry[session_id], :model, nothing)
+    purgesession(model, session_id)
 end
 
 
@@ -82,10 +84,10 @@ end
     @in reset_bonito_session = false
 
     @onchange isready begin
-        genie_session = GenieSession.session()
-        bonito_session_id = genie_session.id[1:32]
-        if haskey(bonito_models, bonito_session_id)
-            sizefactor = sizefactors_str[bonito_models[bonito_session_id].sizefactor]
+        bonito_session_id = getsessionid(session_registry)
+        if haskey(session_registry[bonito_session_id], :model)
+            model = session_registry[bonito_session_id, :model]
+            sizefactor = sizefactors_str[model.sizefactor]
         else
             BonitoServer.addsession(bonito_app, bonito_session_id)
         end
@@ -103,9 +105,10 @@ end
 
     @onchange sizefactor begin
         new_sizefactor = sizefactors[sizefactor]
-        if new_sizefactor != bonito_models[bonito_session_id].sizefactor
+        model = session_registry[bonito_session_id, :model]
+        if new_sizefactor != model.sizefactor
             @info "New size factor; refreshing the iframe"
-            bonito_models[bonito_session_id].sizefactor = new_sizefactor
+            model.sizefactor = new_sizefactor
             BonitoServer.addsession(bonito_app, bonito_session_id; restart=true) # restart
             width = appwidth[sizefactor]
             height = appheight[sizefactor]
@@ -119,7 +122,11 @@ end
     end
 
     @onbutton reset_bonito_session begin
-        purgesession(bonito_session_id)
+        try
+            purgesession(bonito_session_id)
+        catch
+            @error "Error while resetting session"
+        end
         # reset UI to defaults
         bonito_session_id[!] = ""
         sizefactor[!] = "1.0"
diff --git a/src/apps/muscles/Backbone.jl b/src/apps/muscles/Backbone.jl
index 5547f559f2007fab4c3a50d8d513536747340842..e833bebe2ec0837a427baf7011d7bfe586d3b8d8 100644
--- a/src/apps/muscles/Backbone.jl
+++ b/src/apps/muscles/Backbone.jl
@@ -3,6 +3,7 @@ module Backbone
 using NyxWidgets.Base: Cache
 using NyxUI.MuscleActivities
 using NyxUI.Storage
+using NyxUI.GenieExtras
 using Bonito
 
 include("MuscleWidgets.jl")
@@ -32,37 +33,35 @@ export hasmodel, getmodel, setmodel, newsequence, getsequence, withsequences,
 
 ## models
 
-const __backbone_cache__ = Cache{String, MuscleWidget}()
-const __valid_sessions__ = Dict{String, Bool}()
+function purgesession end
+
+const session_registry = SessionRegistry(purgesession, (:session_id, ))
 
 function app(persistent=""; title="Nyx muscle activity")
     App(; title=title) do session
-        # # embedded apps are always renderded twice; save time # EDIT: except on Ctrl+R
-        # valid = get(__valid_sessions__, persistent, false)
-        # __valid_sessions__[persistent] = !valid
-        # valid || return
         @info "New session" session.id
         cache_id = isempty(persistent) ? session.id : persistent
-        model = __backbone_cache__[cache_id]
+        model = session_registry[cache_id, :model]
         Bonito.jsrender(session, model)
     end
 end
 
 ## muscle model
 
-getmodel(session_id) = __backbone_cache__[session_id]#.widget
-
-# for debugging
-getmodel() = first(values(__backbone_cache__))#.widget
+getmodel(session_id) = session_registry[session_id, :model]
 
-hasmodel(session_id) = haskey(__backbone_cache__, session_id)
+hasmodel(session_id) = haskey(session_registry[session_id], :model)
 
 function setmodel(session_id, sequence)
-    __backbone_cache__[session_id] = MuscleWidget(sequence)
+    records = getsession(session_registry, session_id)
+    records[:model] = model = MuscleWidget(sequence)
+    # init :sequences
+    get!(records, :sequences) do
+        Cache{Int, MuscleActivity}()
+    end
+    return model
 end
 
-# getchannel(session_id) = __backbone_cache__[session_id].channel
-
 ## sequence model
 
 function newsequencename(stem, existing)
@@ -97,14 +96,13 @@ function newsequence(session_id, sequence_name, sequence_names, start_time, time
     return sequence
 end
 
-const __sequences__ = Cache{String, Cache{Int, MuscleActivity}}()
-
 function getsequence(session_id, sequence_name)
     model = getmodel(session_id)
     sequence = nothing
     if model.sequence[].program_name != sequence_name
-        lock(__sequences__[session_id]) do
-            for outer sequence in values(__sequences__[session_id])
+        sequences = session_registry[session_id, :sequences]
+        lock(sequences) do
+            for outer sequence in values(sequences)
                 sequence.program_name == sequence_name && break
             end
         end
@@ -116,16 +114,16 @@ end
 getsequence(session_id) = getmodel(session_id).sequence[]
 
 function addsequence(session_id, sequence)
-    lock(__sequences__[session_id]) do
-        sequences = __sequences__[session_id]
+    sequences = session_registry[session_id, :sequences]
+    lock(sequences) do
         i = isempty(sequences) ? 0 : maximum(keys(sequences))
         sequences[i+1] = sequence
     end
 end
 
 function withsequences(f, session_id)
-    lock(__sequences__[session_id]) do
-        sequences = __sequences__[session_id]
+    sequences = session_registry[session_id, :sequences]
+    lock(sequences) do
         return f(sequences)
     end
 end
@@ -153,7 +151,7 @@ end
 function deletesequence(session_id)
     model = getmodel(session_id)
     current = model.sequence[].program_name
-    sequences = __sequences__[session_id]
+    sequences = session_registry[session_id, :sequences]
     lock(sequences) do
         for (ix, seq) in pairs(sequences)
             if seq.program_name == current
@@ -167,19 +165,7 @@ function deletesequence(session_id)
 end
 
 function purgesession(session_id; appname="muscles")
-   model = lock(__backbone_cache__) do
-        if session_id in keys(__backbone_cache__.cache)
-            #pop!(__valid_sessions__, session_id) # not sure what to do with it
-            pop!(__backbone_cache__.cache, session_id)
-        end
-    end
-    lock(__sequences__) do
-        if session_id in keys(__sequences__.cache)
-            pop!(__sequences__.cache, session_id)
-        end
-    end
-    purge_appdata(session_id, appname)
-    return model
+   purge_appdata(session_id, appname)
 end
 
 end
diff --git a/src/apps/muscles/app.jl b/src/apps/muscles/app.jl
index 6140bd2cb844c63fc6efdb74953b6a4ca7958b12..044e4cccdfc0cdf4fe2c0c067892be1daf998909 100644
--- a/src/apps/muscles/app.jl
+++ b/src/apps/muscles/app.jl
@@ -2,7 +2,7 @@ module MuscleApp
 
 using NyxUI, NyxUI.Storage, NyxUI.GenieExtras
 using Observables
-using Genie, GenieSession, Stipple, StippleUI
+using Genie, Stipple, StippleUI
 import Stipple: @app, @init, @private, @in, @out, @onchange, @onbutton, @notify
 
 include("Backbone.jl")
@@ -20,6 +20,8 @@ const maxlength = getconfig("muscle-activity", "maxlength")
 
 const bonito_app = NamedApp(:inherit, Backbone.app)
 
+Backbone.purgesession(session_id) = purgesession(session_id; appname=bonito_app.name)
+
 @app begin
     @private bonito_session_id = ""
 
@@ -95,7 +97,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
     end
 
     @onbutton reset_bonito_session begin
-        model = purgesession(bonito_session_id; appname=bonito_app.name)
+        purgesession(bonito_session_id)
         # reset UI to defaults
         #bonito_session_id[!] = ""
         sequence[!] = MuscleActivity("")
@@ -445,8 +447,7 @@ function init_model(muscle_model=nothing)
         muscle_model = @init
         #@show muscle_model
     end
-    genie_session = GenieSession.session()
-    session_id = genie_session.id[1:32]
+    session_id = getsessionid(Backbone.session_registry)
     if hasmodel(session_id)
         withsequences(session_id) do sequences
             ids = sort!(collect(keys(sequences)))