diff --git a/auth/auth.go b/auth/auth.go
index 93a0ecf4..2dde3858 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -14,8 +14,8 @@ type Auther interface {
 	Authenticate(username, password string) (*User, error)
 
 	AuthenticateToken(token string) (*User, error)
-
-	GenerateToken(user *User) (string, error)
+	CreateToken(user *User) (string, error)
+	RemoveToken(user *User) error
 
 	// Authorize returns nil if the given user has access to the given topic using the desired
 	// permission. The user param may be nil to signal an anonymous user.
@@ -62,6 +62,7 @@ type Manager interface {
 type User struct {
 	Name     string
 	Hash     string // password hash (bcrypt)
+	Token    string // Only set if token was used to log in
 	Role     Role
 	Grants   []Grant
 	Language string
diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go
index 536d9e30..d61d176b 100644
--- a/auth/auth_sqlite.go
+++ b/auth/auth_sqlite.go
@@ -102,6 +102,7 @@ const (
 	deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?`
 
 	insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)`
+	deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?`
 )
 
 // Schema management queries
@@ -138,7 +139,7 @@ func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth
 	}, nil
 }
 
-// AuthenticateUser checks username and password and returns a user if correct. The method
+// Authenticate checks username and password and returns a user if correct. The method
 // returns in constant-ish time, regardless of whether the user exists or the password is
 // correct or incorrect.
 func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) {
@@ -162,10 +163,11 @@ func (a *SQLiteAuth) AuthenticateToken(token string) (*User, error) {
 	if err != nil {
 		return nil, ErrUnauthenticated
 	}
+	user.Token = token
 	return user, nil
 }
 
-func (a *SQLiteAuth) GenerateToken(user *User) (string, error) {
+func (a *SQLiteAuth) CreateToken(user *User) (string, error) {
 	token := util.RandomString(tokenLength)
 	expires := 1 // FIXME
 	if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires); err != nil {
@@ -174,6 +176,16 @@ func (a *SQLiteAuth) GenerateToken(user *User) (string, error) {
 	return token, nil
 }
 
+func (a *SQLiteAuth) RemoveToken(user *User) error {
+	if user.Token == "" {
+		return ErrUnauthorized
+	}
+	if _, err := a.db.Exec(deleteTokenQuery, user.Name, user.Token); err != nil {
+		return err
+	}
+	return nil
+}
+
 // Authorize returns nil if the given user has access to the given topic using the desired
 // permission. The user param may be nil to signal an anonymous user.
 func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error {
diff --git a/server/server.go b/server/server.go
index 75f49d6f..23ef010a 100644
--- a/server/server.go
+++ b/server/server.go
@@ -34,6 +34,17 @@ import (
 	"heckel.io/ntfy/util"
 )
 
+/*
+	TODO
+		expire tokens
+		auto-refresh tokens from UI
+		pricing page
+		home page
+
+
+
+*/
+
 // Server is the main server, providing the UI and API for ntfy
 type Server struct {
 	config            *Config
@@ -71,7 +82,7 @@ var (
 
 	webConfigPath    = "/config.js"
 	userStatsPath    = "/user/stats" // FIXME get rid of this in favor of /user/account
-	userAuthPath     = "/user/auth"
+	userTokenPath    = "/user/token"
 	userAccountPath  = "/user/account"
 	matrixPushPath   = "/_matrix/push/v1/notify"
 	staticRegex      = regexp.MustCompile(`^/static/.+`)
@@ -306,8 +317,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
 		return s.handleUserStats(w, r, v)
-	} else if r.Method == http.MethodGet && r.URL.Path == userAuthPath {
-		return s.handleUserAuth(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == userTokenPath {
+		return s.handleUserTokenCreate(w, r, v)
+	} else if r.Method == http.MethodDelete && r.URL.Path == userTokenPath {
+		return s.handleUserTokenDelete(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == userAccountPath {
 		return s.handleUserAccount(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
@@ -408,16 +421,16 @@ type tokenAuthResponse struct {
 	Token string `json:"token"`
 }
 
-func (s *Server) handleUserAuth(w http.ResponseWriter, r *http.Request, v *visitor) error {
+func (s *Server) handleUserTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	// TODO rate limit
 	if v.user == nil {
 		return errHTTPUnauthorized
 	}
-	token, err := s.auth.GenerateToken(v.user)
+	token, err := s.auth.CreateToken(v.user)
 	if err != nil {
 		return err
 	}
-	w.Header().Set("Content-Type", "text/json")
+	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 	response := &tokenAuthResponse{
 		Token: token,
@@ -428,6 +441,18 @@ func (s *Server) handleUserAuth(w http.ResponseWriter, r *http.Request, v *visit
 	return nil
 }
 
+func (s *Server) handleUserTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	// TODO rate limit
+	if v.user == nil || v.user.Token == "" {
+		return errHTTPUnauthorized
+	}
+	if err := s.auth.RemoveToken(v.user); err != nil {
+		return err
+	}
+	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
+	return nil
+}
+
 type userSubscriptionResponse struct {
 	BaseURL string `json:"base_url"`
 	Topic   string `json:"topic"`
@@ -454,7 +479,7 @@ type userAccountResponse struct {
 }
 
 func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error {
-	w.Header().Set("Content-Type", "text/json")
+	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
 	response := &userAccountResponse{}
 	if v.user != nil {
@@ -1136,7 +1161,7 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
 }
 
 func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
-	w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
+	w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
 	w.Header().Set("Access-Control-Allow-Origin", "*")  // CORS, allow cross-origin requests
 	w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible?
 	return nil
diff --git a/web/src/app/Api.js b/web/src/app/Api.js
index c106a280..6f692a6a 100644
--- a/web/src/app/Api.js
+++ b/web/src/app/Api.js
@@ -7,7 +7,7 @@ import {
     topicUrlJsonPoll,
     topicUrlJsonPollWithSince,
     userAccountUrl,
-    userAuthUrl,
+    userTokenUrl,
     userStatsUrl
 } from "./utils";
 import userManager from "./UserManager";
@@ -119,8 +119,8 @@ class Api {
         throw new Error(`Unexpected server response ${response.status}`);
     }
 
-    async userAuth(baseUrl, user) {
-        const url = userAuthUrl(baseUrl);
+    async login(baseUrl, user) {
+        const url = userTokenUrl(baseUrl);
         console.log(`[Api] Checking auth for ${url}`);
         const response = await fetch(url, {
             headers: maybeWithBasicAuth({}, user)
@@ -135,6 +135,18 @@ class Api {
         return json.token;
     }
 
+    async logout(baseUrl, token) {
+        const url = userTokenUrl(baseUrl);
+        console.log(`[Api] Logging out from ${url} using token ${token}`);
+        const response = await fetch(url, {
+            method: "DELETE",
+            headers: maybeWithBearerAuth({}, token)
+        });
+        if (response.status !== 200) {
+            throw new Error(`Unexpected server response ${response.status}`);
+        }
+    }
+
     async userStats(baseUrl) {
         const url = userStatsUrl(baseUrl);
         console.log(`[Api] Fetching user stats ${url}`);
diff --git a/web/src/app/Session.js b/web/src/app/Session.js
index 1ae8606a..06b5f8f7 100644
--- a/web/src/app/Session.js
+++ b/web/src/app/Session.js
@@ -9,6 +9,10 @@ class Session {
         localStorage.removeItem("token");
     }
 
+    exists() {
+        return this.username() && this.token();
+    }
+
     username() {
         return localStorage.getItem("user");
     }
diff --git a/web/src/app/utils.js b/web/src/app/utils.js
index 36184090..d72c4c3f 100644
--- a/web/src/app/utils.js
+++ b/web/src/app/utils.js
@@ -19,7 +19,7 @@ export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJ
 export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
 export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
 export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
-export const userAuthUrl = (baseUrl) => `${baseUrl}/user/auth`;
+export const userTokenUrl = (baseUrl) => `${baseUrl}/user/token`;
 export const userAccountUrl = (baseUrl) => `${baseUrl}/user/account`;
 export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
 export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js
index 5d4b5847..ffd08751 100644
--- a/web/src/components/ActionBar.js
+++ b/web/src/components/ActionBar.js
@@ -246,7 +246,7 @@ const ProfileIcon = (props) => {
     const { t } = useTranslation();
     const [open, setOpen] = useState(false);
     const anchorRef = useRef(null);
-    const username = session.username();
+    const navigate = useNavigate();
 
     const handleToggleOpen = () => {
         setOpen((prevOpen) => !prevOpen);
@@ -272,7 +272,8 @@ const ProfileIcon = (props) => {
         // TODO
     };
 
-    const handleLogout = () => {
+    const handleLogout = async () => {
+        await api.logout("http://localhost:2586"/*window.location.origin*/, session.token());
         session.reset();
         window.location.href = routes.app;
     };
@@ -288,15 +289,15 @@ const ProfileIcon = (props) => {
 
     return (
         <>
-            {username &&
+            {session.exists() &&
                 <IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} sx={{marginRight: 0}} aria-label={t("xxxxxxx")}>
                     <AccountCircleIcon/>
                 </IconButton>
             }
-            {!username &&
+            {!session.exists() &&
                 <>
-                    <Button>Sign in</Button>
-                    <Button>Sign up</Button>
+                    <Button color="inherit" variant="outlined" onClick={() => navigate(routes.login)}>Sign in</Button>
+                    <Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)}>Sign up</Button>
                 </>
             }
             <Popper
diff --git a/web/src/components/Login.js b/web/src/components/Login.js
index 50edd8d7..8ae73a0a 100644
--- a/web/src/components/Login.js
+++ b/web/src/components/Login.js
@@ -36,7 +36,7 @@ const Login = () => {
             username: data.get('email'),
             password: data.get('password'),
         }
-        const token = await api.userAuth("http://localhost:2586"/*window.location.origin*/, user);
+        const token = await api.login("http://localhost:2586"/*window.location.origin*/, user);
         console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`);
         session.store(user.username, token);
         window.location.href = routes.app;
diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.js
index 9a4baf7c..b4804022 100644
--- a/web/src/components/Notifications.js
+++ b/web/src/components/Notifications.js
@@ -84,7 +84,10 @@ const NotificationList = (props) => {
     useEffect(() => {
         return () => {
             setMaxCount(pageSize);
-            document.getElementById("main").scrollTo(0, 0);
+            const main = document.getElementById("main");
+            if (main) {
+                main.scrollTo(0, 0);
+            }
         }
     }, [props.id]);
 
diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js
index f23a053d..f5c23f93 100644
--- a/web/src/components/Preferences.js
+++ b/web/src/components/Preferences.js
@@ -441,6 +441,11 @@ const Language = () => {
     const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
     const lang = i18n.language ?? "en";
 
+    const handleChange = async (ev) => {
+        await i18n.changeLanguage(ev.target.value);
+        //api.update
+    };
+
     // Remember: Flags are not languages. Don't put flags next to the language in the list.
     // Languages names from: https://www.omniglot.com/language/names.htm
     // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
@@ -448,7 +453,7 @@ const Language = () => {
     return (
         <Pref labelId={labelId} title={title}>
             <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
-                <Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}>
+                <Select value={lang} onChange={handleChange} aria-labelledby={labelId}>
                     <MenuItem value="en">English</MenuItem>
                     <MenuItem value="id">Bahasa Indonesia</MenuItem>
                     <MenuItem value="bg">Български</MenuItem>
@@ -474,6 +479,10 @@ const Language = () => {
     )
 };
 
+const AccessControl = () => {
+    return <></>;
+}
+/*
 const AccessControl = () => {
     const { t } = useTranslation();
     const [dialogKey, setDialogKey] = useState(0);
@@ -632,6 +641,6 @@ const AccessControlDialog = (props) => {
         </Dialog>
     );
 };
-
+*/
 
 export default Preferences;
diff --git a/web/src/components/routes.js b/web/src/components/routes.js
index 299f5285..27b36736 100644
--- a/web/src/components/routes.js
+++ b/web/src/components/routes.js
@@ -4,6 +4,7 @@ import {shortUrl} from "../app/utils";
 const routes = {
     home: "/",
     login: "/login",
+    signup: "/signup",
     app: config.appRoot,
     settings: "/settings",
     subscription: "/:topic",