You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Express会话ID请求变更及Safari IPT拦截问题求助

问题:Safari智能跟踪防护(ITP)拦截Express会话,导致登录状态丢失

我基于Express开发简易登录系统,登录页向服务器发请求后,服务器通过express-session创建会话,但登录后主页面检查会话状态时,始终显示未登录,且两次请求的sessionID不同。已配置CORS {credentials: true, origin: true} 和fetch的 credentials: "include",问题仍存在。排查后确认是Safari的Intelligent Tracking Prevention(ITP)导致,关闭ITP后功能正常,但不能要求所有用户关闭该设置,求解决方法。


相关代码

Express服务器代码

const express = require("express");
const app = express();
const cors = require("cors");
const session = require("express-session");
const crypto = require("crypto");
const bcrypt = require("bcrypt");
const fs = require("fs/promises");
const process = require("process");

const usersFilePath = "./users.json";

app.use(express.json());
app.use(cors({credentials: true, origin: true}));
app.use(
    session({
        name: "app.sid",
        secret: crypto.randomBytes(32).toString("hex"),
        resave: false,
        saveUninitialized: true,
        cookie: {secure: false, sameSite: "none"},
        store: new session.MemoryStore()
    })
)

let users = [];

async function loadUsers() {
    try {
        const data = await fs.readFile(usersFilePath);
        users = JSON.parse(data);
        console.log("Found existing user data, loading...");
    } catch (error) {
        users = [];
        console.log("Could not find existing user data")
    }
}

async function saveUsers() {
    await fs.writeFile(usersFilePath, JSON.stringify(users, null, 2));
}

app.get("/users", (req, res) => {
    res.json(users);
});

app.post("/users/register", async (req, res) => {
    const existingUser = users.find((user) => user.name === req.body.name);
    if (existingUser != null) {
        return res.status(400).send("User already exists");
    }

    try {
        const hashedPassword = await bcrypt.hash(req.body.password, 10);
        const user = { name: req.body.name, password: hashedPassword };
        users.push(user);
        await saveUsers();
        res.status(201).send();
    } catch (error) {
        res.status(500).send("error LOL");
    }
});

app.post("/users/login", async (req, res) => {
    const user = users.find((user) => user.name === req.body.name);
    if (user == null) {
        return res.status(400).send("User does not exist");
    }

    try {
        if (await bcrypt.compare(req.body.password, user.password)) {
            req.session.user = user;
            console.log("Logged in user: " + req.session.user.name);
            console.log("sessionID: " + req.sessionID);
            res.status(200).json({sessionID: req.sessionID});
        } else {
            res.send("Wrong Password!");
        }
    } catch (error) {
        res.status(500).send(error);
    }
});

app.post("/users/logout", (req, res) => {
    req.session.destroy((err) => {
        if (err) {
            res.status(500).send("Logout failed");
        } else {
            res.status(200).send("Logout successful");
        }
    })
});

app.get("/users/status", (req, res) => {
    console.log("User: " + req.session.user);
    console.log("sessionID: " + req.sessionID);
    if (req.session.user) {
        res.json({loggedIn: true, username: req.session.user.name});
    } else {
        res.json({loggedIn: false, sessionID: req.sessionID});
        console.log("User not logged in")
    }
})

// Load existing users when the server starts
loadUsers().then(() => {
    const server = app.listen(3000, () => {
        console.log("Server is running on port 3000");
    });
});

登录页面脚本

document.getElementById('loginForm').addEventListener('submit', async function (event) {
    event.preventDefault();

    const username = document.getElementById('username').value;
    const password = document.getElementById('password').value;

    try {
        const response = await fetch('http://localhost:3000/users/login/', {
            method: 'POST',
            credentials: "include",
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ name: username, password: password }),
        });

        const data = await response.json();
        console.log("SessionID: " + data.sessionID);

        // setTimeout(() => {
        //     window.open("../index.html", "_self");
        // }, 1);
    } catch (error) {
        console.error('Error during login:', error);
    }
});

登录状态检查脚本

async function checkLoginStatus() {
    try {
      const response = await fetch('http://localhost:3000/users/status', {
        method: "GET",
        credentials: "include",
        headers: {
            "Content-Type": "application/json"
        },
      });
      const data = await response.json();
      console.log("SessionID: " + data.sessionID);

      const info = document.getElementById('userInfo');
      const button = document.getElementById('logoutButton');

      if (data.loggedIn) {
        // User is logged in
        info.innerText = `Logged in as ${data.username}`;
        button.style.display = 'Log out';
        button.onclick = "logout()";

      } else {
        // User is not logged in
        info.innerText = 'Not logged in';
        button.innerText = "Log in";
        button.onclick = () => window.open("auth/login.html", "_self");
      }
    } catch (error) {
      console.error('Error checking login status:', error);
    }
  }

  async function logout() {
    try {
      const response = await fetch('http://localhost:3000/users/logout', { method: 'POST' });
      if (response.status === 200) {
        // User successfully logged out
        checkLoginStatus(); // Update UI
      } else {
        console.error('Logout failed');
      }
    } catch (error) {
      console.error('Error during logout:', error);
    }
  }

  checkLoginStatus();

解决方案

1. 修正Session Cookie的SameSite与Secure属性

ITP对跨站Cookie限制严格,sameSite: "none"必须配合secure: true生效,因为SameSite=None的Cookie仅允许在HTTPS环境下传输。开发环境可通过ngrok等工具模拟HTTPS,生产环境必须启用HTTPS。

修改session配置中的cookie部分:

cookie: {
  secure: process.env.NODE_ENV === "production", // 生产环境强制开启secure
  sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
  httpOnly: true, // 禁止前端脚本访问Cookie,降低ITP跟踪识别概率
  maxAge: 24 * 60 * 60 * 1000 // 设置明确过期时间,避免被ITP标记为无期限会话清理
}

2. 禁用saveUninitialized

当前saveUninitialized: true会在用户未登录时创建空会话,这类空会话更容易被ITP判定为跟踪Cookie。改为仅在用户登录成功后创建会话:

session({
  // ...其他配置
  saveUninitialized: false,
  // ...
})

3. 改用Token-Based认证替代Session

ITP主要限制第三方Cookie,改用JWT Token认证,将Token存储在localStorage或sessionStorage中,请求时通过请求头携带,完全绕开Cookie限制。

示例修改:

  • 后端登录接口返回JWT:
// 先安装依赖:npm install jsonwebtoken
const jwt = require("jsonwebtoken");

app.post("/users/login", async (req, res) => {
  // ...用户验证逻辑
  if (await bcrypt.compare(req.body.password, user.password)) {
    const token = jwt.sign({ name: user.name }, process.env.JWT_SECRET, { expiresIn: "1d" });
    res.status(200).json({ token });
  } else {
    res.send("Wrong Password!");
  }
})
  • 前端存储并携带Token:
// 登录成功后存储Token
localStorage.setItem("authToken", data.token);

// 检查状态时携带Token
async function checkLoginStatus() {
  const token = localStorage.getItem("authToken");
  const response = await fetch('http://localhost:3000/users/status', {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${token}`
    },
  });
  // ...后续逻辑
}
  • 后端验证Token中间件:
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  if (!token) return res.sendStatus(401);

  jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
    if (err) return res.sendStatus(403);
    req.user = user;
    next();
  });
}

app.get("/users/status", authenticateToken, (req, res) => {
  res.json({ loggedIn: true, username: req.user.name });
})

4. 配置Cookie的Domain属性(跨域场景)

若前后端域名不同,将Cookie的domain设置为父域名,让子域名共享Cookie,减少ITP的跨站识别概率。例如后端为api.example.com,前端为app.example.com:

cookie: {
  domain: ".example.com",
  // ...其他配置
}

5. 改用持久化Session存储

当前使用MemoryStore,服务器重启后会话丢失,且ITP对临时存储的会话容忍度低。改用connect-redis等持久化存储:

// 安装依赖:npm install connect-redis redis
const RedisStore = require("connect-redis").default;
const { createClient } = require("redis");

const redisClient = createClient({ url: "redis://localhost:6379" });
redisClient.connect().catch(console.error);

app.use(session({
  // ...其他配置
  store: new RedisStore({ client: redisClient }),
}))

内容的提问来源于stack exchange,提问作者Cric_Hammel

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.02 13:45:01