Oct 05, 2025
7 min read

JWT Authentication Across Ports: Multi-Server Auth Without Session Cookies

Building a blog platform where authentication flows seamlessly between a main site and multiple blog instances on different ports

I needed to build a blog platform where users get their own blog instance running on a separate port from the main site. Simple enough, right? Except authentication became a fascinating puzzle. How do you let users log in on the main site (port 3000) and have that authentication work on their blog (port 4321) without sharing session cookies across ports?

Turns out the answer is JWT tokens, but not in the way most tutorials show you. This isn’t about API authentication. This is about seamless authentication across multiple server instances that each maintain their own session state.

The Architecture Problem

Here’s what I was building:

  • Main site on port 3000: handles user registration, OAuth, magic links, dashboard
  • Individual blog instances on different ports: one per user, each running its own Astro server
  • Requirement: users must be able to edit their blog without re-authenticating

The naive approach won’t work. Session cookies are domain-specific and won’t transfer between localhost:3000 and localhost:4321. Even if they could, each blog is a separate Express instance with its own session store.

JWT as the Bridge, Not the Destination

The key insight was treating JWT as a temporary authentication bridge, not as the permanent authentication mechanism. Here’s the flow:

  1. User authenticates on main site (OAuth or magic link)
  2. Main site generates a JWT token
  3. Main site redirects to blog with JWT in query params
  4. Blog verifies JWT and creates its own session cookie
  5. Blog stores session in memory for subsequent requests

The JWT only exists for that one redirect. After that, it’s regular session-based authentication on the blog side.

The Main Site: Token Generation

Here’s the JWT utility that generates tokens:

const jwt = require('jsonwebtoken');

const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
const JWT_EXPIRY = '24h';

function generateBlogAuthToken(user, blogName) {
  const payload = {
    userId: user.id,
    email: user.email,
    blogName: blogName,
    type: 'blog_auth'
  };

  return jwt.sign(payload, JWT_SECRET, {
    expiresIn: JWT_EXPIRY,
    issuer: 'blog-platform'
  });
}

Nothing fancy here. The critical detail is the type: 'blog_auth' field. This prevents token reuse for other purposes. If someone intercepts this token, they can’t use it to call other APIs or authenticate elsewhere.

The Blog Side: Token Verification

The blog needs to verify the token and convert it to a session. Here’s the middleware:

function verifyBlogOwnership(req, res, next) {
  const authCookie = req.cookies.blog_auth;

  if (!authCookie) {
    return res.status(401).json({
      error: 'Authentication required. Please log in through the main site.',
      loginUrl: 'http://localhost:3000/login'
    });
  }

  try {
    const authData = typeof authCookie === 'string' ? JSON.parse(authCookie) : authCookie;
    const blogName = process.env.BLOG_NAME;

    // Verify the user owns this blog
    if (authData.blogName !== blogName) {
      return res.status(403).json({
        error: 'Access denied. You do not own this blog.'
      });
    }

    // Check if auth is not expired (24 hours)
    const authAge = Date.now() - authData.timestamp;
    if (authAge > 24 * 60 * 60 * 1000) {
      return res.status(401).json({
        error: 'Authentication expired. Please log in again.'
      });
    }

    req.blogUser = {
      userId: authData.userId,
      blogName: authData.blogName
    };

    next();
  } catch (error) {
    console.error('Blog auth middleware error:', error);
    return res.status(401).json({
      error: 'Invalid authentication. Please log in again.'
    });
  }
}

Notice what’s happening here: we’re not verifying the JWT on every request. That only happens once during the callback. After that, we’re verifying a session cookie with user data stored in a plain JavaScript object.

The Security Model

This architecture has some interesting security properties:

  1. Short-lived tokens: JWTs expire in 24 hours, limiting the damage if intercepted
  2. Single-use conversion: The JWT is only used once to establish the session
  3. Blog-specific validation: Each blog verifies the user actually owns it
  4. Issuer verification: Tokens must come from 'blog-platform' issuer
  5. Type verification: Only 'blog_auth' type tokens are accepted

The JWT payload looks like this:

{
  "userId": "uuid",
  "email": "user@example.com",
  "blogName": "username",
  "type": "blog_auth",
  "iat": 1234567890,
  "exp": 1234654290,
  "iss": "blog-platform"
}

The Shared Secret Problem

The elephant in the room: both the main site and all blog instances need to share the same JWT_SECRET. In development, this is easy. In production, it’s a configuration management challenge.

I went with environment variables:

# Main site .env
JWT_SECRET=your-super-secret-jwt-key-change-in-production

# Blog .env
JWT_SECRET=your-super-secret-jwt-key-change-in-production

For production deployments, you’d want to:

  • Use a secrets management service (AWS Secrets Manager, HashiCorp Vault)
  • Rotate the secret periodically
  • Use different secrets for different environments
  • Never commit the secret to version control (obviously)

Magic links added another layer of complexity. The user requests a link, clicks it in email, and needs to end up authenticated. Here’s how that works:

  1. User requests magic link on blog
  2. Blog forwards request to main site API
  3. Main site generates token with email verification code
  4. Email sent with link: http://localhost:3000/verify?token=...&blog=username
  5. User clicks link, main site verifies token
  6. Main site generates blog auth JWT
  7. Main site redirects to blog with JWT
  8. Blog verifies JWT and creates session

The critical detail: the magic link doesn’t go directly to the blog. It goes through the main site, which can verify the email token against its database before issuing the blog auth JWT.

Why Not Just Use JWT Everywhere?

You might be wondering: why bother with session cookies on the blog side? Why not just verify the JWT on every request?

Three reasons:

  1. Performance: JWT verification is slower than in-memory session lookup
  2. Revocation: You can’t revoke a JWT without a blacklist, but you can clear a session
  3. Simplicity: Session-based auth is easier to reason about and debug

The blog instances are single-user applications. They don’t need the scalability benefits of stateless JWT auth. They need the simplicity of “is this user logged in?”

The Implementation Reality

Here’s what the actual callback handler looks like on the blog side:

app.get('/api/auth/callback', (req, res) => {
  const { token } = req.query;

  if (!token) {
    return res.status(400).json({ error: 'No token provided' });
  }

  // Verify JWT (this only happens once)
  const decoded = verifyBlogAuthToken(token);

  if (!decoded) {
    return res.status(401).json({ error: 'Invalid token' });
  }

  // Create session cookie
  res.cookie('blog_auth', JSON.stringify({
    userId: decoded.userId,
    blogName: decoded.blogName,
    timestamp: Date.now()
  }), {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 24 * 60 * 60 * 1000 // 24 hours
  });

  res.redirect('/editor');
});

Simple. Clean. The JWT is verified once, converted to a session, and then forgotten.

Testing the Flow

The trickiest part of debugging this was understanding where tokens were being generated and verified. I added extensive logging:

console.log('Generating JWT for user:', user.email, 'blog:', blogName);
console.log('JWT payload:', payload);
console.log('Token expires in:', JWT_EXPIRY);

And on the verification side:

console.log('Verifying JWT token');
console.log('Decoded userId:', decoded.userId);
console.log('Expected blog:', blogName);
console.log('Token blog:', decoded.blogName);

This saved me hours of “why isn’t this working” debugging. Turns out I had a case sensitivity bug in blog name comparison.

The Bottom Line

JWT authentication across multiple server instances isn’t about replacing sessions. It’s about bridging authentication between systems that can’t share session state.

Use JWTs for:

  • Cross-origin authentication
  • Temporary authentication bridges
  • Delegating authentication to a central authority

Don’t use JWTs for:

  • Every single request (unless you really need stateless auth)
  • Storing large amounts of user data
  • Authentication you might need to revoke immediately

The real lesson here? Choose the right tool for each part of the problem. JWTs got users from the main site to their blog. Sessions kept them logged in once they were there. Neither one could solve the whole problem alone.

References