diff --git a/backend/db/queries/queries.user.sql.go b/backend/db/queries/queries.user.sql.go
index 5276e83..8d983e2 100644
--- a/backend/db/queries/queries.user.sql.go
+++ b/backend/db/queries/queries.user.sql.go
@@ -5,23 +5,27 @@ package queries
 import (
 	"context"
 	"fmt"
+	"github.com/jackc/pgtype"
 	"github.com/jackc/pgx/v4"
 )
 
 const getUserByIDSQL = `SELECT * FROM users WHERE id = $1;`
 
 type GetUserByIDRow struct {
-	ID              string         `json:"id"`
-	Username        string         `json:"username"`
-	DisplayName     *string        `json:"display_name"`
-	Bio             *string        `json:"bio"`
-	AvatarUrls      []string       `json:"avatar_urls"`
-	Links           []string       `json:"links"`
-	Discord         *string        `json:"discord"`
-	DiscordUsername *string        `json:"discord_username"`
-	MaxInvites      int32          `json:"max_invites"`
-	Names           []FieldEntry   `json:"names"`
-	Pronouns        []PronounEntry `json:"pronouns"`
+	ID              string             `json:"id"`
+	Username        string             `json:"username"`
+	DisplayName     *string            `json:"display_name"`
+	Bio             *string            `json:"bio"`
+	AvatarUrls      []string           `json:"avatar_urls"`
+	Links           []string           `json:"links"`
+	Discord         *string            `json:"discord"`
+	DiscordUsername *string            `json:"discord_username"`
+	MaxInvites      int32              `json:"max_invites"`
+	Names           []FieldEntry       `json:"names"`
+	Pronouns        []PronounEntry     `json:"pronouns"`
+	DeletedAt       pgtype.Timestamptz `json:"deleted_at"`
+	SelfDelete      *bool              `json:"self_delete"`
+	DeleteReason    *string            `json:"delete_reason"`
 }
 
 // GetUserByID implements Querier.GetUserByID.
@@ -31,7 +35,7 @@ func (q *DBQuerier) GetUserByID(ctx context.Context, id string) (GetUserByIDRow,
 	var item GetUserByIDRow
 	namesArray := q.types.newFieldEntryArray()
 	pronounsArray := q.types.newPronounEntryArray()
-	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil {
+	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil {
 		return item, fmt.Errorf("query GetUserByID: %w", err)
 	}
 	if err := namesArray.AssignTo(&item.Names); err != nil {
@@ -54,7 +58,7 @@ func (q *DBQuerier) GetUserByIDScan(results pgx.BatchResults) (GetUserByIDRow, e
 	var item GetUserByIDRow
 	namesArray := q.types.newFieldEntryArray()
 	pronounsArray := q.types.newPronounEntryArray()
-	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil {
+	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil {
 		return item, fmt.Errorf("scan GetUserByIDBatch row: %w", err)
 	}
 	if err := namesArray.AssignTo(&item.Names); err != nil {
@@ -69,17 +73,20 @@ func (q *DBQuerier) GetUserByIDScan(results pgx.BatchResults) (GetUserByIDRow, e
 const getUserByUsernameSQL = `SELECT * FROM users WHERE username = $1;`
 
 type GetUserByUsernameRow struct {
-	ID              string         `json:"id"`
-	Username        string         `json:"username"`
-	DisplayName     *string        `json:"display_name"`
-	Bio             *string        `json:"bio"`
-	AvatarUrls      []string       `json:"avatar_urls"`
-	Links           []string       `json:"links"`
-	Discord         *string        `json:"discord"`
-	DiscordUsername *string        `json:"discord_username"`
-	MaxInvites      int32          `json:"max_invites"`
-	Names           []FieldEntry   `json:"names"`
-	Pronouns        []PronounEntry `json:"pronouns"`
+	ID              string             `json:"id"`
+	Username        string             `json:"username"`
+	DisplayName     *string            `json:"display_name"`
+	Bio             *string            `json:"bio"`
+	AvatarUrls      []string           `json:"avatar_urls"`
+	Links           []string           `json:"links"`
+	Discord         *string            `json:"discord"`
+	DiscordUsername *string            `json:"discord_username"`
+	MaxInvites      int32              `json:"max_invites"`
+	Names           []FieldEntry       `json:"names"`
+	Pronouns        []PronounEntry     `json:"pronouns"`
+	DeletedAt       pgtype.Timestamptz `json:"deleted_at"`
+	SelfDelete      *bool              `json:"self_delete"`
+	DeleteReason    *string            `json:"delete_reason"`
 }
 
 // GetUserByUsername implements Querier.GetUserByUsername.
@@ -89,7 +96,7 @@ func (q *DBQuerier) GetUserByUsername(ctx context.Context, username string) (Get
 	var item GetUserByUsernameRow
 	namesArray := q.types.newFieldEntryArray()
 	pronounsArray := q.types.newPronounEntryArray()
-	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil {
+	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil {
 		return item, fmt.Errorf("query GetUserByUsername: %w", err)
 	}
 	if err := namesArray.AssignTo(&item.Names); err != nil {
@@ -112,7 +119,7 @@ func (q *DBQuerier) GetUserByUsernameScan(results pgx.BatchResults) (GetUserByUs
 	var item GetUserByUsernameRow
 	namesArray := q.types.newFieldEntryArray()
 	pronounsArray := q.types.newPronounEntryArray()
-	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil {
+	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil {
 		return item, fmt.Errorf("scan GetUserByUsernameBatch row: %w", err)
 	}
 	if err := namesArray.AssignTo(&item.Names); err != nil {
@@ -137,17 +144,20 @@ type UpdateUserNamesPronounsParams struct {
 }
 
 type UpdateUserNamesPronounsRow struct {
-	ID              string         `json:"id"`
-	Username        string         `json:"username"`
-	DisplayName     *string        `json:"display_name"`
-	Bio             *string        `json:"bio"`
-	AvatarUrls      []string       `json:"avatar_urls"`
-	Links           []string       `json:"links"`
-	Discord         *string        `json:"discord"`
-	DiscordUsername *string        `json:"discord_username"`
-	MaxInvites      int32          `json:"max_invites"`
-	Names           []FieldEntry   `json:"names"`
-	Pronouns        []PronounEntry `json:"pronouns"`
+	ID              string             `json:"id"`
+	Username        string             `json:"username"`
+	DisplayName     *string            `json:"display_name"`
+	Bio             *string            `json:"bio"`
+	AvatarUrls      []string           `json:"avatar_urls"`
+	Links           []string           `json:"links"`
+	Discord         *string            `json:"discord"`
+	DiscordUsername *string            `json:"discord_username"`
+	MaxInvites      int32              `json:"max_invites"`
+	Names           []FieldEntry       `json:"names"`
+	Pronouns        []PronounEntry     `json:"pronouns"`
+	DeletedAt       pgtype.Timestamptz `json:"deleted_at"`
+	SelfDelete      *bool              `json:"self_delete"`
+	DeleteReason    *string            `json:"delete_reason"`
 }
 
 // UpdateUserNamesPronouns implements Querier.UpdateUserNamesPronouns.
@@ -157,7 +167,7 @@ func (q *DBQuerier) UpdateUserNamesPronouns(ctx context.Context, params UpdateUs
 	var item UpdateUserNamesPronounsRow
 	namesArray := q.types.newFieldEntryArray()
 	pronounsArray := q.types.newPronounEntryArray()
-	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil {
+	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil {
 		return item, fmt.Errorf("query UpdateUserNamesPronouns: %w", err)
 	}
 	if err := namesArray.AssignTo(&item.Names); err != nil {
@@ -180,7 +190,7 @@ func (q *DBQuerier) UpdateUserNamesPronounsScan(results pgx.BatchResults) (Updat
 	var item UpdateUserNamesPronounsRow
 	namesArray := q.types.newFieldEntryArray()
 	pronounsArray := q.types.newPronounEntryArray()
-	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil {
+	if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil {
 		return item, fmt.Errorf("scan UpdateUserNamesPronounsBatch row: %w", err)
 	}
 	if err := namesArray.AssignTo(&item.Names); err != nil {
diff --git a/backend/db/tokens.go b/backend/db/tokens.go
index 6e07367..3a1ddd5 100644
--- a/backend/db/tokens.go
+++ b/backend/db/tokens.go
@@ -96,3 +96,16 @@ func (db *DB) InvalidateToken(ctx context.Context, userID xid.ID, tokenID xid.ID
 	}
 	return t, nil
 }
+
+func (db *DB) InvalidateAllTokens(ctx context.Context, q querier, userID xid.ID) error {
+	sql, args, err := sq.Update("tokens").Where("user_id = ?", userID).Set("invalidated", true).ToSql()
+	if err != nil {
+		return errors.Wrap(err, "building sql")
+	}
+
+	_, err = q.Exec(ctx, sql, args...)
+	if err != nil {
+		return errors.Wrap(err, "executing query")
+	}
+	return nil
+}
diff --git a/backend/db/user.go b/backend/db/user.go
index 506806f..8aae9f4 100644
--- a/backend/db/user.go
+++ b/backend/db/user.go
@@ -3,11 +3,13 @@ package db
 import (
 	"context"
 	"regexp"
+	"time"
 
 	"codeberg.org/u1f320/pronouns.cc/backend/db/queries"
 	"emperror.dev/errors"
 	"github.com/bwmarrin/discordgo"
 	"github.com/jackc/pgconn"
+	"github.com/jackc/pgtype"
 	"github.com/jackc/pgx/v4"
 	"github.com/rs/xid"
 )
@@ -28,6 +30,10 @@ type User struct {
 	DiscordUsername *string
 
 	MaxInvites int
+
+	DeletedAt    *time.Time
+	SelfDelete   *bool
+	DeleteReason *string
 }
 
 // usernames must match this regex
@@ -134,6 +140,11 @@ func (db *DB) getUser(ctx context.Context, q querier, id xid.ID) (u User, err er
 		return u, errors.Wrap(err, "getting user from database")
 	}
 
+	var deletedAt *time.Time
+	if qu.DeletedAt.Status == pgtype.Present {
+		deletedAt = &qu.DeletedAt.Time
+	}
+
 	u = User{
 		ID:              id,
 		Username:        qu.Username,
@@ -146,6 +157,9 @@ func (db *DB) getUser(ctx context.Context, q querier, id xid.ID) (u User, err er
 		Discord:         qu.Discord,
 		DiscordUsername: qu.DiscordUsername,
 		MaxInvites:      int(qu.MaxInvites),
+		DeletedAt:       deletedAt,
+		SelfDelete:      qu.SelfDelete,
+		DeleteReason:    qu.DeleteReason,
 	}
 
 	return u, nil
@@ -283,3 +297,20 @@ func (db *DB) UpdateUser(
 	}
 	return u, nil
 }
+
+func (db *DB) DeleteUser(ctx context.Context, q querier, id xid.ID, selfDelete bool, reason string) error {
+	builder := sq.Update("users").Set("deleted_at", time.Now().UTC()).Set("self_delete", selfDelete).Where("id = ?", id)
+	if !selfDelete {
+		builder = builder.Set("delete_reason", reason)
+	}
+	sql, args, err := builder.ToSql()
+	if err != nil {
+		return errors.Wrap(err, "building sql")
+	}
+
+	_, err = q.Exec(ctx, sql, args...)
+	if err != nil {
+		return errors.Wrap(err, "executing query")
+	}
+	return nil
+}
diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go
index 8b1bed5..deb6eba 100644
--- a/backend/routes/auth/discord.go
+++ b/backend/routes/auth/discord.go
@@ -84,7 +84,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
 
 		// TODO: implement user + token permissions
 		tokenID := xid.New()
-		token, err := s.Auth.CreateToken(u.ID, tokenID, false, true)
+		token, err := s.Auth.CreateToken(u.ID, tokenID, false, true, true)
 		if err != nil {
 			return err
 		}
@@ -217,7 +217,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
 	// create token
 	// TODO: implement user + token permissions
 	tokenID := xid.New()
-	token, err := s.Auth.CreateToken(u.ID, tokenID, false, true)
+	token, err := s.Auth.CreateToken(u.ID, tokenID, false, true, true)
 	if err != nil {
 		return errors.Wrap(err, "creating token")
 	}
diff --git a/backend/routes/user/delete_user.go b/backend/routes/user/delete_user.go
new file mode 100644
index 0000000..80b498a
--- /dev/null
+++ b/backend/routes/user/delete_user.go
@@ -0,0 +1,42 @@
+package user
+
+import (
+	"net/http"
+
+	"codeberg.org/u1f320/pronouns.cc/backend/server"
+	"emperror.dev/errors"
+	"github.com/go-chi/render"
+)
+
+func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	claims, _ := server.ClaimsFromContext(ctx)
+
+	if claims.APIToken || !claims.TokenWrite {
+		return server.APIError{Code: server.ErrMissingPermissions}
+	}
+
+	tx, err := s.DB.Begin(ctx)
+	if err != nil {
+		return errors.Wrap(err, "creating transaction")
+	}
+	defer tx.Rollback(ctx)
+
+	err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "")
+	if err != nil {
+		return errors.Wrap(err, "setting user as deleted")
+	}
+
+	err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID)
+	if err != nil {
+		return errors.Wrap(err, "invalidating tokens")
+	}
+
+	err = tx.Commit(ctx)
+	if err != nil {
+		return errors.Wrap(err, "committing transaction")
+	}
+
+	render.NoContent(w, r)
+	return nil
+}
diff --git a/backend/routes/user/routes.go b/backend/routes/user/routes.go
index 76b428c..550fa75 100644
--- a/backend/routes/user/routes.go
+++ b/backend/routes/user/routes.go
@@ -18,6 +18,7 @@ func Mount(srv *server.Server, r chi.Router) {
 		r.With(server.MustAuth).Group(func(r chi.Router) {
 			r.Get("/@me", server.WrapHandler(s.getMeUser))
 			r.Patch("/@me", server.WrapHandler(s.patchUser))
+			r.Delete("/@me", server.WrapHandler(s.deleteUser))
 		})
 	})
 }
diff --git a/backend/server/auth/auth.go b/backend/server/auth/auth.go
index 07f1072..e9d2bfb 100644
--- a/backend/server/auth/auth.go
+++ b/backend/server/auth/auth.go
@@ -18,6 +18,9 @@ type Claims struct {
 	TokenID     xid.ID `json:"jti"`
 	UserIsAdmin bool   `json:"adm"`
 
+	// APIToken specifies whether this token was generated for the API or for the website.
+	// API tokens cannot perform some destructive actions, such as DELETE /users/@me.
+	APIToken bool `json:"atn"`
 	// TokenWrite specifies whether this token can be used for write actions.
 	// If set to false, this token can only be used for read actions.
 	TokenWrite bool `json:"twr"`
@@ -48,7 +51,7 @@ const ExpireDays = 30
 
 // CreateToken creates a token for the given user ID.
 // It expires after 30 days.
-func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isWriteToken bool) (token string, err error) {
+func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isAPIToken bool, isWriteToken bool) (token string, err error) {
 	now := time.Now()
 	expires := now.Add(ExpireDays * 24 * time.Hour)
 
@@ -56,6 +59,7 @@ func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isWriteToke
 		UserID:      userID,
 		TokenID:     tokenID,
 		UserIsAdmin: isAdmin,
+		APIToken:    isAPIToken,
 		TokenWrite:  isWriteToken,
 		RegisteredClaims: jwt.RegisteredClaims{
 			Issuer:    "pronouns",
diff --git a/backend/server/errors.go b/backend/server/errors.go
index 25da292..6701268 100644
--- a/backend/server/errors.go
+++ b/backend/server/errors.go
@@ -83,6 +83,7 @@ const (
 	ErrInvitesDisabled    = 1008 // invites are disabled (unneeded)
 	ErrInviteLimitReached = 1009 // invite limit reached (when creating invites)
 	ErrInviteAlreadyUsed  = 1010 // invite already used (when signing up)
+	ErrDeletionPending    = 1011 // own user deletion pending, returned with undo code
 
 	// User-related error codes
 	ErrUserNotFound = 2001
@@ -94,7 +95,8 @@ const (
 	ErrNotOwnMember       = 3004
 
 	// General request error codes
-	ErrRequestTooBig = 4001
+	ErrRequestTooBig      = 4001
+	ErrMissingPermissions = 4002
 )
 
 var errCodeMessages = map[int]string{
@@ -115,6 +117,7 @@ var errCodeMessages = map[int]string{
 	ErrInvitesDisabled:    "Invites are disabled",
 	ErrInviteLimitReached: "Your account has reached the invite limit",
 	ErrInviteAlreadyUsed:  "That invite code has already been used",
+	ErrDeletionPending:    "Your account is pending deletion",
 
 	ErrUserNotFound: "User not found",
 
@@ -123,7 +126,8 @@ var errCodeMessages = map[int]string{
 	ErrMemberNameInUse:    "Member name already in use",
 	ErrNotOwnMember:       "Not your member",
 
-	ErrRequestTooBig: "Request too big (max 2 MB)",
+	ErrRequestTooBig:      "Request too big (max 2 MB)",
+	ErrMissingPermissions: "Your account or current token is missing required permissions for this action",
 }
 
 var errCodeStatuses = map[int]int{
@@ -144,6 +148,7 @@ var errCodeStatuses = map[int]int{
 	ErrInvitesDisabled:    http.StatusForbidden,
 	ErrInviteLimitReached: http.StatusForbidden,
 	ErrInviteAlreadyUsed:  http.StatusBadRequest,
+	ErrDeletionPending:    http.StatusBadRequest,
 
 	ErrUserNotFound: http.StatusNotFound,
 
@@ -152,5 +157,6 @@ var errCodeStatuses = map[int]int{
 	ErrMemberNameInUse:    http.StatusBadRequest,
 	ErrNotOwnMember:       http.StatusForbidden,
 
-	ErrRequestTooBig: http.StatusBadRequest,
+	ErrRequestTooBig:      http.StatusBadRequest,
+	ErrMissingPermissions: http.StatusForbidden,
 }
diff --git a/scripts/migrate/005_delete_users.sql b/scripts/migrate/005_delete_users.sql
new file mode 100644
index 0000000..3bb2ac1
--- /dev/null
+++ b/scripts/migrate/005_delete_users.sql
@@ -0,0 +1,10 @@
+-- +migrate Up
+
+-- 2023-03-07: add delete functionality
+
+-- if not null, the user is soft deleted
+alter table users add column deleted_at timestamptz;
+-- if true, the user deleted their account themselves + should have option to reactivate; should also be deleted after 30 days
+alter table users add column self_delete boolean;
+-- delete reason if the user was deleted by a moderator
+alter table users add column delete_reason text;