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
- What is authentication?
- Session vs token authentication
- JWT basics
- JWT implementation (Node.js)
- Refresh token strategy
- OAuth 2.0 basics (authorization code, PKCE, token endpoint, scope & consent, production)
- OAuth 2.0 implementation
- Kakao OAuth implementation
- Security best practices
- Real-world patterns
- Comparison table
- Troubleshooting
- Production checklist
- Full example: auth system
- 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:
- Session: store login state on the server
- Token: the client carries credentials (a token) on each request
3. What is a cookie?
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
- Split into three segments; reject malformed tokens.
- Allowlist
alg— rejectnoneand unexpected algorithms (algorithm-confusion mitigation). - Verify HS256 with a shared secret or RS256 with the IdP public key (cache JWKS by
kid). - Only then read claims; enforce
exp, andnbf/iatif present. - For federated tokens, enforce
issandaudagainst 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):
- Authorization request — Browser sent to the authorization endpoint with
response_type=code. - Authentication & consent — The authorization server authenticates the user and collects consent.
- Redirect — User returns to
redirect_uriwithcode=.... If you sentstate, validate it to block CSRF. - Token request — Client (usually your backend) POSTs to the token endpoint with
grant_type=authorization_code, thecode, matchingredirect_uri, client id, and optional PKCE verifier. - 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_challenge —
S256hash of the verifier, base64url-encoded. - Authorization request includes
code_challengeandcode_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_type—authorization_codecode— The code from the redirectredirect_uri— Often must exactly match the authorize stepclient_id— Registered application id- Confidential clients —
client_secretor 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.
Scope and consent
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:
- Exact redirect URIs — Match scheme, host, port, path, and trailing slash to the provider console; most
redirect_uri_mismatchissues are here. state+ PKCE —statefor CSRF; PKCE whenever the client is public or you want defense in depth.- 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. - Short access tokens + refresh rotation — Shrinks theft windows; detect refresh-token reuse when supported.
- OIDC — Verify
id_tokensignatures via JWKS and usenonceagainst replay when applicable. - 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
| Aspect | Session | JWT | OAuth 2.0 |
|---|---|---|---|
| Where state lives | Server memory/DB | Client | Third-party IdP |
| Scalability | Lower | Higher | Higher |
| Forced logout | Easy | Harder | Easy (revoke at IdP / your session) |
| CORS | More constrained | Flexible | Flexible |
| Mobile | Harder | Easier | Easier |
| Complexity | Lower | Medium | Higher |
| Security posture | Strong with cookies | Depends on storage | Strong 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
Related reading
- JWT authentication complete guide
- Node.js JWT authentication guide
- Node.js authentication and security
Keywords: JWT, OAuth, Authentication, Token, Access Token, Refresh Token, OAuth 2.0, Security, social login