OAuth 2.0 & JWT authentication — token login, refresh flows, Passport, Google & Kakao

OAuth 2.0 & JWT authentication — token login, refresh flows, Passport, Google & Kakao

이 글의 핵심

How stateless JWT verification works and how to attach external login with OAuth then issue your own service tokens—focused on deployment FAQs rather than a pure theory checklist.

At a glance

This article walks through verifying statelessly with JWTs and, with OAuth, attaching external login and then issuing your own service tokens—with examples geared to questions that come up in production, not an abstract checklist alone.


Table of contents

  1. What is authentication?
  2. Session vs token authentication
  3. JWT basics
  4. JWT implementation (Node.js)
  5. Refresh token strategy
  6. OAuth 2.0 basics (authorization code, PKCE, token endpoint, scope & consent, production)
  7. OAuth 2.0 implementation
  8. Kakao OAuth implementation
  9. Security best practices
  10. Real-world patterns
  11. Comparison table
  12. Troubleshooting
  13. Production checklist
  14. Full example: auth system
  15. Testing

Prerequisites (fundamentals for beginners)

1. Authentication vs authorization

Authentication: “Who are you?”

Example login flow:
- The user sends ID and password
- The server verifies identity and returns a mechanism for later requests (session, token, etc.)

Authorization: “What are you allowed to do?”

Permission check example:
- On a delete request, inspect role (admin vs user)
- Reject if policy does not allow it

Authentication is who someone is; authorization is what they may do. Mixing the two blurs your design.

2. HTTP is stateless

Stateless means the server does not remember previous requests.

Problem:
Request 1: POST /login (success!)
Request 2: GET /profile (who is this?)
Request 3: GET /posts (who is this?)
HTTP treats each request independently, so the server does not “remember” that the user logged in.

Mitigations:

  1. Session: store login state on the server
  2. Token: the client carries credentials (a token) on each request

A cookie is small data the browser stores and sends back on subsequent requests to the same site.

// Server sets a cookie
res.cookie('sessionId', 'abc123', {
  httpOnly: true,  // Not readable from JavaScript (security)
  secure: true,    // HTTPS only
  maxAge: 3600000  // 1 hour
});
// Later requests include the cookie automatically
// Cookie: sessionId=abc123

1. What is authentication?

Why authentication matters

Without authentication:
- Anyone could read others’ data
- Anyone could delete posts
- Security incidents

With authentication:
- Only logged-in users access protected areas
- Users only see/modify their own data
- Safer service operation

Evolution of authentication styles

Gen 1: Basic Auth
  - Send ID/password on every request
  - Weak (Base64 is not encryption)

Gen 2: Session-based
  - Login state stored on the server
  - Session ID in a cookie
  - Uses server memory

Gen 3: Token-based (JWT)
  - Server avoids per-request session state
  - Claims live in the token
  - Scales well

Gen 4: OAuth 2.0
  - Social login (Google, Kakao, etc.)
  - Delegated third-party authentication

2. Session vs token authentication

Session-based authentication

┌─────────┐                    ┌─────────┐
│ Client  │                    │ Server  │
└────┬────┘                    └────┬────┘
     │                              │
     │ 1. POST /login               │
     │ (ID: alice, PW: 1234)        │
     ├─────────────────────────────>│
     │                              │ 2. Verify password
     │                              │ 3. Create session (in memory)
     │                              │    sessions['abc123'] = { userId: 1 }
     │ 4. Set-Cookie: sessionId=abc123
     │<─────────────────────────────┤
     │                              │
     │ 5. GET /profile              │
     │ Cookie: sessionId=abc123     │
     ├─────────────────────────────>│
     │                              │ 6. Look up session
     │                              │    sessions['abc123'] → userId: 1
     │ 7. { name: "Alice", ....}    │
     │<─────────────────────────────┤

Pros:

  • Server-side session control (forced logout)
  • Session data managed on the server

Cons:

  • Server memory cost at scale
  • Horizontal scaling is harder (session sharing)
  • Cookie/CORS constraints on some clients

Token-based authentication (JWT)

┌─────────┐                    ┌─────────┐
│ Client  │                    │ Server  │
└────┬────┘                    └────┬────┘
     │                              │
     │ 1. POST /login               │
     │ (ID: alice, PW: 1234)        │
     ├─────────────────────────────>│
     │                              │ 2. Verify password
     │                              │ 3. Create JWT (sign)
     │                              │    token = sign({ userId: 1 })
     │ 4. { token: "eyJhbG..." }    │
     │<─────────────────────────────┤
     │ 5. Store in localStorage      │
     │                              │
     │ 6. GET /profile              │
     │ Authorization: Bearer eyJhbG...
     ├─────────────────────────────>│
     │                              │ 7. Verify JWT (signature)
     │                              │    verify(token) → userId: 1
     │ 8. { name: "Alice", ....}    │
     │<─────────────────────────────┤

Pros:

  • No per-request session state on the server (stateless)
  • Easier horizontal scaling
  • Works well for mobile apps

Cons:

  • Larger payloads on every request
  • Forced logout is harder
  • Stolen tokens are dangerous until expiry

3. JWT basics

JWT structure

A JWT (JSON Web Token) has three segments.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTYxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└────────────── Header ──────────────┘ └─────────── Payload ───────────┘ └──────────── Signature ────────────┘

1) Header

{
  "alg": "HS256",
  "typ": "JWT"
}

2) Payload

{
  "userId": 1,
  "email": "[email protected]",
  "role": "admin",
  "iat": 1616239022,
  "exp": 1616242622
}

3) Signature

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

How JWT works

1. After login:
   Server creates JWT → sends to client
2. Later requests:
   Client sends JWT in header
   Authorization: Bearer <token>
3. Server verifies:
   - Signature (tampering)
   - Expiry
   - Read claims from payload

JWT internals — JWS, verification, claims, and refresh

A JWT on the wire is usually a JWS compact token (RFC 7515) carrying JSON claims (RFC 7519). The signature covers base64url(header) + "." + base64url(payload) using the alg from the header. decode does not authenticate — always verify signature and time claims before trusting the payload.

Verification checklist

  1. Split into three segments; reject malformed tokens.
  2. Allowlist alg — reject none and unexpected algorithms (algorithm-confusion mitigation).
  3. Verify HS256 with a shared secret or RS256 with the IdP public key (cache JWKS by kid).
  4. Only then read claims; enforce exp, and nbf/iat if present.
  5. For federated tokens, enforce iss and aud against your API/resource identifiers.

HS256 shares a symmetric secret between issuer and verifier; RS256 uses a private key to sign and a public key (often via JWKS) to verify — common when many services validate tokens without sharing secrets. For OAuth/OIDC, validate iss and aud so tokens minted for another resource server are not accepted. Use jti with rotation, reuse detection, or denylists for logout and theft response.

Refresh in production
Pair short access tokens with longer refresh tokens; rotate refresh tokens and detect reuse (same refresh submitted twice ⇒ revoke session family). Store refresh handles server-side or in HttpOnly cookies with CSRF defenses when using cookies.

The Node.js examples below assume this verification model.


4. JWT implementation (Node.js)

Install packages

npm install express jsonwebtoken bcrypt

Basic implementation

server.js

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const SECRET_KEY = 'your-secret-key-change-this';
const users = []; // Use a real database in production
// Register
app.post('/register', async (req, res) => {
  const { email, password } = req.body;

  const hashedPassword = await bcrypt.hash(password, 10);

  const user = {
    id: users.length + 1,
    email,
    password: hashedPassword
  };

  users.push(user);
  res.status(201).json({ message: 'Registration successful' });
});
// Login
app.post('/login', async (req, res) => {
  const { email, password } = req.body;

  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ error: 'User not found' });
  }

  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) {
    return res.status(401).json({ error: 'Invalid password' });
  }

  const token = jwt.sign(
    { userId: user.id, email: user.email },
    SECRET_KEY,
    { expiresIn: '1h' }
  );

  res.json({ token });
});
// Auth middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"

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

  jwt.verify(token, SECRET_KEY, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid token' });
    }
    req.user = user;
    next();
  });
}
// Protected route
app.get('/profile', authenticateToken, (req, res) => {
  res.json({
    message: 'Profile loaded',
    user: req.user
  });
});
app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Example usage

# 1. Register
curl -X POST http://localhost:3000/register \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"1234"}'
# 2. Login
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"1234"}'
# Response: {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
# 3. Profile (requires token)
curl http://localhost:3000/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

5. Refresh token strategy

Problem: access token expiry

Access token TTL: 15 minutes
After 15 minutes:
- User is still using the app
- Token expires → kicked to login 😱
- Bad UX

Solution: refresh token

Access token (short-lived):
- TTL: e.g. 15 minutes
- Used for API calls
- Limits damage if stolen

Refresh token (long-lived):
- TTL: e.g. 7 days
- Used only to obtain new access tokens
- Stored server-side (enables forced logout)

Refresh token implementation

const jwt = require('jsonwebtoken');
const ACCESS_TOKEN_SECRET = 'access-secret';
const REFRESH_TOKEN_SECRET = 'refresh-secret';
let refreshTokens = []; // Use a database in production
// Login
app.post('/login', async (req, res) => {
  const { email, password } = req.body;

  // Authenticate user (omitted)
  const user = { id: 1, email };

  const accessToken = jwt.sign(
    { userId: user.id, email: user.email },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { userId: user.id },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );

  refreshTokens.push(refreshToken);

  res.json({ accessToken, refreshToken });
});
// Refresh access token
app.post('/refresh', (req, res) => {
  const { refreshToken } = req.body;

  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }

  if (!refreshTokens.includes(refreshToken)) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }

  jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'Expired refresh token' });
    }

    const accessToken = jwt.sign(
      { userId: user.userId },
      ACCESS_TOKEN_SECRET,
      { expiresIn: '15m' }
    );

    res.json({ accessToken });
  });
});
// Logout
app.post('/logout', (req, res) => {
  const { refreshToken } = req.body;

  refreshTokens = refreshTokens.filter(token => token !== refreshToken);

  res.json({ message: 'Logged out' });
});

Client example (React)

async function apiRequest(url, options = {}) {
  let accessToken = localStorage.getItem('accessToken');

  options.headers = {
    ...options.headers,
    'Authorization': `Bearer ${accessToken}`
  };

  let response = await fetch(url, options);

  if (response.status === 401) {
    const refreshToken = localStorage.getItem('refreshToken');
    const refreshResponse = await fetch('/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken })
    });

    if (refreshResponse.ok) {
      const { accessToken: newAccessToken } = await refreshResponse.json();
      localStorage.setItem('accessToken', newAccessToken);

      options.headers['Authorization'] = `Bearer ${newAccessToken}`;
      response = await fetch(url, options);
    } else {
      window.location.href = '/login';
    }
  }

  return response;
}
const data = await apiRequest('/api/profile').then(r => r.json());

6. OAuth 2.0 basics

What is OAuth 2.0?

OAuth 2.0 is a protocol for delegating access so third-party apps can reach resources with user consent.

Analogy:

Hotel valet:
- You: resource owner (car owner)
- Valet: third-party app
- Car: protected resource
- Valet key: access token (limited—cannot open the trunk)

OAuth similarly:
- Google account (resource)
- Our app (third-party)
- Limited scope (e.g. email only)

OAuth 2.0 flow

┌─────────┐                  ┌─────────┐                  ┌─────────┐
│  User   │                  │ Our App │                  │ Google  │
│         │                  │         │                  │ (OAuth) │
└────┬────┘                  └────┬────┘                  └────┬────┘
     │                            │                            │
     │ 1. Click "Sign in with Google"                         │
     ├───────────────────────────>│                            │
     │                            │ 2. Redirect to Google login │
     │                            ├───────────────────────────>│
     │ 3. Google login UI         │                            │
     │<────────────────────────────────────────────────────────┤
     │                            │                            │
     │ 4. Enter credentials, approve consent                    │
     ├────────────────────────────────────────────────────────>│
     │                            │                            │ 5. Authorization code
     │ 6. Redirect back with code │                            │
     │<────────────────────────────────────────────────────────┤
     │                            │                            │
     │ 7. Send code to our app    │                            │
     ├───────────────────────────>│                            │
     │                            │ 8. Exchange code for tokens
     │                            ├───────────────────────────>│
     │                            │ 9. Access token issued      │
     │                            │<───────────────────────────┤
     │                            │ 10. Fetch user profile      │
     │                            ├───────────────────────────>│
     │                            │ 11. User info (email, etc.) │
     │                            │<───────────────────────────┤
     │ 12. Login complete         │                            │
     │<───────────────────────────┤                            │

Key OAuth 2.0 terms

1) Resource owner — the end user

2) Client — your application

3) Authorization server — e.g. Google, Kakao OAuth endpoints

4) Resource server — API that serves protected user data

5) Authorization code — short-lived code exchanged for tokens

6) Access token — credential for calling protected APIs

Authorization code flow — internals

The diagram above is essentially the authorization code grant. The leg where the browser hops to Google and back is often called the front channel (redirects), while exchanging the code for tokens is usually a back-channel server-to-server POST to the token endpoint. That split matters for security and debugging.

Why pass a short-lived code instead of an access token in the redirect?
Among other reasons, you avoid putting bearer credentials in URLs where they can leak via history, logs, or Referer. The one-time code rides the redirect; the token is minted in a controlled HTTPS call. Confidential clients (web backends) can add client authentication when calling the token endpoint. Public clients (mobile apps, many SPAs) cannot safely store a client secret—PKCE mitigates interception of the code (see below).

Typical steps (RFC 6749 concepts):

  1. Authorization request — Browser sent to the authorization endpoint with response_type=code.
  2. Authentication & consent — The authorization server authenticates the user and collects consent.
  3. Redirect — User returns to redirect_uri with code=.... If you sent state, validate it to block CSRF.
  4. Token request — Client (usually your backend) POSTs to the token endpoint with grant_type=authorization_code, the code, matching redirect_uri, client id, and optional PKCE verifier.
  5. Resource access — Use the access token against the resource server or userinfo endpoint.

Also distinguish client types: confidential servers can hold secrets; public clients rely on PKCE, exact redirect URIs, and short-lived tokens.

PKCE (Proof Key for Code Exchange)

PKCE makes it hard for an attacker who steals an authorization code to exchange it for tokens without the original code verifier. The client generates a high-entropy code_verifier, derives a code_challenge (typically BASE64URL(SHA256(verifier)) with method S256), sends only the challenge on /authorize, and later sends the plaintext verifier to the token endpoint so the server can re-hash and compare.

At a glance:

  • code_verifier — Long, unpredictable secret (commonly 43–128 URL-safe characters).
  • code_challengeS256 hash of the verifier, base64url-encoded.
  • Authorization request includes code_challenge and code_challenge_method=S256.
  • Token request includes code_verifier.

For SPAs and mobile apps that cannot use a client secret, authorization code + PKCE is the modern default. OAuth 2.1 tightens expectations: new authorization-code implementations should use PKCE.

Token endpoint

The token endpoint issues and refreshes tokens over HTTPS. Besides exchanging an authorization code, it handles refresh_token grants and sometimes client-credentials or other grants depending on registration.

Typical parameters for code exchange (conceptual):

  • grant_typeauthorization_code
  • code — The code from the redirect
  • redirect_uri — Often must exactly match the authorize step
  • client_id — Registered application id
  • Confidential clients — client_secret or HTTP Basic client authentication
  • With PKCE — code_verifier

Successful JSON responses usually include access_token, token_type (often Bearer), and expires_in. OpenID Connect adds an id_token (JWT) for identity. Errors return error and optional error_description—log them for ops, but mask secrets and codes in dashboards.

Scopes describe what the issued token may do—often a space-delimited list aligned to provider-defined APIs (profile, email, calendar, etc.). The consent screen is where the user explicitly approves those scopes; asking for the minimum needed reduces drop-off. Incremental authorization requests extra scopes later when a feature needs them, instead of front-loading everything.

Do not conflate IdP OAuth scopes with your application’s RBAC. After social login, most products still create a local user record and issue your own session or API token where product roles and permissions live.

Production OAuth patterns

Patterns that show up repeatedly in production:

  1. Exact redirect URIs — Match scheme, host, port, path, and trailing slash to the provider console; most redirect_uri_mismatch issues are here.
  2. state + PKCEstate for CSRF; PKCE whenever the client is public or you want defense in depth.
  3. Token storage — Prefer HttpOnly Secure cookies where possible to reduce XSS impact; SPAs often use a BFF (backend-for-frontend) so tokens never live in localStorage.
  4. Short access tokens + refresh rotation — Shrinks theft windows; detect refresh-token reuse when supported.
  5. OIDC — Verify id_token signatures via JWKS and use nonce against replay when applicable.
  6. Observability — Chart authorize failures and token-endpoint error rates next to support tickets to spot misconfigured clients or IdP incidents.

Libraries like Passport hide much of this, but when you debug mobile deep links, enterprise IdPs, or security reviews, these primitives are what you need to reason about quickly.


7. OAuth 2.0 implementation

Google OAuth setup

1) Google Cloud Console

1. Open https://console.cloud.google.com
2. Create a project
3. APIs & Services → Credentials
4. Create OAuth 2.0 Client ID
5. Add authorized redirect URI:
   http://localhost:3000/auth/google/callback
6. Copy Client ID and Client Secret

Implementation with Passport.js

npm install passport passport-google-oauth20 express-session

server.js

const express = require('express');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const session = require('express-session');
const app = express();
app.use(session({
  secret: 'session-secret',
  resave: false,
  saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());
passport.use(new GoogleStrategy({
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: 'http://localhost:3000/auth/google/callback'
  },
  (accessToken, refreshToken, profile, done) => {
    const user = {
      id: profile.id,
      email: profile.emails[0].value,
      name: profile.displayName,
      picture: profile.photos[0].value
    };

    return done(null, user);
  }
));
passport.serializeUser((user, done) => {
  done(null, user.id);
});
passport.deserializeUser((id, done) => {
  const user = { id, email: '[email protected]' };
  done(null, user);
});
app.get('/auth/google',
  passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => {
    res.redirect('/dashboard');
  }
);
app.get('/logout', (req, res, next) => {
  req.logout((err) => {
    if (err) return next(err);
    res.redirect('/');
  });
});
app.get('/dashboard', (req, res) => {
  if (!req.isAuthenticated()) {
    return res.redirect('/login');
  }
  res.json({ user: req.user });
});
app.listen(3000);

Minimal front-end

<!DOCTYPE html>
<html>
<head>
  <title>OAuth Login</title>
</head>
<body>
  <h1>Sign in</h1>

  <a href="/auth/google">
    <button>Sign in with Google</button>
  </a>

  <a href="/auth/kakao">
    <button>Sign in with Kakao</button>
  </a>
</body>
</html>

8. Kakao OAuth implementation

Kakao Developer setup

1. Open https://developers.kakao.com
2. Create an application
3. Platform → add Web
4. Set Redirect URI:
   http://localhost:3000/auth/kakao/callback
5. Configure consent items (email, profile)
6. Copy REST API key

passport-kakao

npm install passport-kakao
const KakaoStrategy = require('passport-kakao').Strategy;
passport.use(new KakaoStrategy({
    clientID: process.env.KAKAO_CLIENT_ID,
    callbackURL: 'http://localhost:3000/auth/kakao/callback'
  },
  (accessToken, refreshToken, profile, done) => {
    const user = {
      id: profile.id,
      email: profile._json.kakao_account.email,
      name: profile.displayName,
      picture: profile._json.properties.profile_image
    };

    return done(null, user);
  }
));
app.get('/auth/kakao',
  passport.authenticate('kakao')
);
app.get('/auth/kakao/callback',
  passport.authenticate('kakao', { failureRedirect: '/login' }),
  (req, res) => {
    res.redirect('/dashboard');
  }
);

9. Security best practices

JWT security

1) Secret management

// Bad: hard-coded
const SECRET = 'my-secret-key';
// Good: environment variable
const SECRET = process.env.JWT_SECRET;
// .env
JWT_SECRET=randomly-generated-long-secret-key-256-bits

2) HTTPS only

// Bad: tokens over HTTP
http://example.com/api?token=eyJhbG...
// Good: HTTPS
https://example.com/api
Authorization: Bearer eyJhbG...

3) Short expiry

// Bad: very long TTL
jwt.sign(payload, secret, { expiresIn: '30d' });
// Good: short access token + refresh
jwt.sign(payload, secret, { expiresIn: '15m' });

4) Do not put secrets in the JWT

// Bad
const token = jwt.sign({ userId: 1, password: '1234' }, secret);
// Good: minimal claims
const token = jwt.sign({ userId: 1, role: 'user' }, secret);

XSS defenses

XSS injects malicious scripts into pages.

// Risky: token in localStorage (readable by XSS)
localStorage.setItem('token', token);
// Safer: HttpOnly cookie
res.cookie('token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict'
});

CSRF defenses

CSRF sends requests on behalf of a logged-in user without their intent.

const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.use(csrfProtection);
app.get('/form', (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/process', csrfProtection, (req, res) => {
  res.send('Done');
});
<form method="POST" action="/process">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>">
  <button type="submit">Submit</button>
</form>

10. Real-world patterns

Role-based access control (RBAC)

function requireRole(role) {
  return (req, res, next) => {
    if (req.user.role !== role) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}
app.delete('/users/:id',
  authenticateToken,
  requireRole('admin'),
  (req, res) => {
    res.json({ message: 'Deleted' });
  }
);
function requireAnyRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}
app.get('/admin/dashboard',
  authenticateToken,
  requireAnyRole('admin', 'moderator'),
  (req, res) => {
    res.json({ message: 'Admin dashboard' });
  }
);

Token denylist

const redis = require('redis');
const client = redis.createClient();
app.post('/logout', authenticateToken, async (req, res) => {
  const token = req.headers['authorization'].split(' ')[1];

  const decoded = jwt.decode(token);
  const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);

  await client.setex(`blacklist:${token}`, expiresIn, 'true');

  res.json({ message: 'Logged out' });
});
async function authenticateToken(req, res, next) {
  const token = req.headers['authorization']?.split(' ')[1];

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

  const isBlacklisted = await client.get(`blacklist:${token}`);
  if (isBlacklisted) {
    return res.status(401).json({ error: 'Token revoked' });
  }

  jwt.verify(token, SECRET_KEY, (err, user) => {
    if (err) return res.status(403).json({ error: 'Invalid token' });
    req.user = user;
    next();
  });
}

11. Comparison table

AspectSessionJWTOAuth 2.0
Where state livesServer memory/DBClientThird-party IdP
ScalabilityLowerHigherHigher
Forced logoutEasyHarderEasy (revoke at IdP / your session)
CORSMore constrainedFlexibleFlexible
MobileHarderEasierEasier
ComplexityLowerMediumHigher
Security postureStrong with cookiesDepends on storageStrong when configured correctly

When to choose what

Sessions:
- Traditional server-rendered web apps
- Single server or shared session store
- You need strong server-side revocation

JWT:
- REST APIs and microservices
- Mobile clients
- Horizontal scale without session stickiness

OAuth 2.0:
- Social login
- Accessing third-party APIs on behalf of users
- Delegated authorization to Google/Kakao/etc.

12. Troubleshooting

Common issues

1) jwt malformed

// Cause: malformed token string
// Fix: strip Bearer prefix correctly
const token = authHeader.split(' ')[1];

2) jwt expired

if (err.name === 'TokenExpiredError') {
  return res.status(401).json({ error: 'expired', message: 'Token expired' });
}

3) CORS errors

const cors = require('cors');
app.use(cors({
  origin: 'http://localhost:3001',
  credentials: true
}));

4) OAuth callback errors

Cause: redirect URI mismatch
Fix:
1. Verify provider console settings
2. Exact URI (http vs https, port, path)
3. Local test: http://localhost:3000/auth/google/callback

13. Production checklist

Security checklist

✅ HTTPS everywhere
✅ Secrets in environment variables
✅ Short access token TTL (e.g. 15m)
✅ Persist refresh tokens in DB
✅ Password hashing (bcrypt, scrypt, Argon2)
✅ Rate limiting on auth endpoints
✅ CORS configured explicitly
✅ HttpOnly, Secure cookies where applicable
✅ XSS: validate and sanitize input
✅ CSRF tokens for cookie-based sessions

Rate limiting

const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: 'Too many login attempts. Try again in 15 minutes.'
});
app.post('/login', loginLimiter, async (req, res) => {
  // login handler
});

14. Full example: auth system

Project layout

auth-system/
├── src/
│   ├── config/
│   │   └── passport.js
│   ├── middleware/
│   │   ├── auth.js
│   │   └── rateLimiter.js
│   ├── models/
│   │   └── User.js
│   ├── routes/
│   │   ├── auth.js
│   │   └── users.js
│   └── server.js
├── .env
└── package.json

src/middleware/auth.js

const jwt = require('jsonwebtoken');
exports.authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

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

  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
    if (err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(401).json({ error: 'expired' });
      }
      return res.status(403).json({ error: 'Invalid token' });
    }
    req.user = user;
    next();
  });
};
exports.requireRole = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
};

src/routes/auth.js

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const router = express.Router();

router.post('/register', async (req, res) => {
  try {
    const { email, password, name } = req.body;

    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ error: 'Email already registered' });
    }

    const hashedPassword = await bcrypt.hash(password, 10);

    const user = await User.create({
      email,
      password: hashedPassword,
      name
    });

    res.status(201).json({
      message: 'Registration successful',
      userId: user.id
    });
  } catch (error) {
    res.status(500).json({ error: 'Server error' });
  }
});

router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ error: 'Invalid email or password' });
    }

    const isValid = await bcrypt.compare(password, user.password);
    if (!isValid) {
      return res.status(401).json({ error: 'Invalid email or password' });
    }

    const accessToken = jwt.sign(
      { userId: user.id, email: user.email, role: user.role },
      process.env.ACCESS_TOKEN_SECRET,
      { expiresIn: '15m' }
    );

    const refreshToken = jwt.sign(
      { userId: user.id },
      process.env.REFRESH_TOKEN_SECRET,
      { expiresIn: '7d' }
    );

    await user.update({ refreshToken });

    res.json({ accessToken, refreshToken });
  } catch (error) {
    res.status(500).json({ error: 'Server error' });
  }
});

router.post('/refresh', async (req, res) => {
  try {
    const { refreshToken } = req.body;

    if (!refreshToken) {
      return res.status(401).json({ error: 'No refresh token' });
    }

    const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);

    const user = await User.findOne({
      id: decoded.userId,
      refreshToken
    });

    if (!user) {
      return res.status(403).json({ error: 'Invalid refresh token' });
    }

    const accessToken = jwt.sign(
      { userId: user.id, email: user.email, role: user.role },
      process.env.ACCESS_TOKEN_SECRET,
      { expiresIn: '15m' }
    );

    res.json({ accessToken });
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Refresh token expired' });
    }
    res.status(403).json({ error: 'Invalid refresh token' });
  }
});

router.post('/logout', async (req, res) => {
  try {
    const { refreshToken } = req.body;

    await User.updateOne(
      { refreshToken },
      { $unset: { refreshToken: 1 } }
    );

    res.json({ message: 'Logged out' });
  } catch (error) {
    res.status(500).json({ error: 'Server error' });
  }
});
module.exports = router;

15. Testing

const request = require('supertest');
const app = require('./server');
describe('Authentication', () => {
  let accessToken;

  it('registers a user', async () => {
    const res = await request(app)
      .post('/register')
      .send({
        email: '[email protected]',
        password: '1234',
        name: 'Test User'
      });

    expect(res.status).toBe(201);
  });

  it('logs in', async () => {
    const res = await request(app)
      .post('/login')
      .send({
        email: '[email protected]',
        password: '1234'
      });

    expect(res.status).toBe(200);
    expect(res.body).toHaveProperty('accessToken');
    accessToken = res.body.accessToken;
  });

  it('accesses protected route', async () => {
    const res = await request(app)
      .get('/profile')
      .set('Authorization', `Bearer ${accessToken}`);

    expect(res.status).toBe(200);
  });

  it('returns 401 without token', async () => {
    const res = await request(app)
      .get('/profile');

    expect(res.status).toBe(401);
  });
});

FAQ

Q1. Where should I store a JWT?

  • localStorage: easy but XSS-risky
  • HttpOnly cookie: safer but needs CSRF protection
  • Recommendation: HttpOnly cookie + CSRF token, or short-lived access token in memory

Q2. What access token TTL should I use?

  • Typical: 15 minutes to 1 hour
  • Sensitive: 5–15 minutes
  • Less sensitive: 1–24 hours

Q3. Where do refresh tokens live?

  • Database for revocation
  • Redis for fast lookup

Q4. Do OAuth 2.0 and JWT go together?

Yes. After the IdP returns user info, you often issue your own session or JWT for your API.

app.get('/auth/google/callback',
  passport.authenticate('google'),
  (req, res) => {
    const token = jwt.sign({ userId: req.user.id }, SECRET);
    res.json({ token });
  }
);

Summary

JWT

  • Token-based authentication
  • Stateless; scales horizontally
  • Pair access tokens with refresh tokens

OAuth 2.0

  • Social and delegated login
  • Third-party authorization
  • Better UX when users already trust Google/Kakao/etc.

Security

  • HTTPS
  • HttpOnly cookies where appropriate
  • Short-lived access tokens
  • Rate limiting

Keywords: JWT, OAuth, Authentication, Token, Access Token, Refresh Token, OAuth 2.0, Security, social login