Next.js 14 Admin Panel Security: Using JWT and PIN Code Authentication with App Router
I am working on my blog project and have created an admin panel with the route /admin. To secure the admin routes, I created an authentication system using a PIN input method that employs JWT and refresh token logic. The refresh token is important because the access token lasts for 1 hour, while the refresh token lasts for 7 days. If the admin is working and the access token cookie expires, it would be inconvenient. This system generates a new access token for the admin to prevent that.
Firstly, I would like to put here jwt utility functions.
import { JWTPayload, SignJWT, jwtVerify } from "jose"
export async function sha256(input: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('')
return hashHex
}
export async function generateKey(secret: string): Promise<CryptoKey> {
const enc = new TextEncoder()
const keyData = enc.encode(secret)
const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' },
true,
['sign', 'verify']
)
return key
}
export async function signJWT(payload: JWTPayload, secret: string, expIn: string): Promise<string> {
const key = await generateKey(secret)
const jwt = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime(expIn)
.sign(key)
return jwt
}
export async function verifyJWT(token: string, secret: string): Promise<JWTPayload | false> {
const key = await generateKey(secret)
try {
const decoded = (await jwtVerify(token, key)).payload
return decoded
} catch (error) {
console.error('JWT verification failed:', error)
return false
}
}
The login page responsible for requesting to the next API login route to create access and refresh tokens, set cookies, and redirect the admin to the management panel or show an invalid pin error message. My next api routes are in /app/api folder. (/app/api/auth/login/route.ts, /api/auth/logout/route.ts, /api/auth/refresh/route.ts)
'use client'
import { useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { routeMap } from '../../routeMap'
// Axios for next js internal api
import { nextApi } from '@/lib/axios'
// Material UI Components
import Container from "@mui/material/Container"
import Box from '@mui/material/Box'
import Typography from "@mui/material/Typography"
// PinField component and its styles
import PinField from 'react-pin-field'
import './styles.scss'
export default function LoginPage() {
const router = useRouter()
const pinRef = useRef<HTMLInputElement[]>([])
const [error, setError] = useState<boolean>(false)
const [pending, setPending] = useState<boolean>(false)
function handleLogin(pin: string) {
setPending(true)
nextApi.post(routeMap.api.login.root, { pin: pin })
.then(_ => router.push(routeMap.admin.root))
.catch(_ => setError(true))
.finally(() => {
setPending(false)
pinRef.current?.forEach(input => (input.value = ""));
pinRef && pinRef.current && pinRef.current[0].focus()
})
}
return (
<Container
maxWidth="sm"
sx={{ textAlign: 'center' }}
>
<Box
sx={{
display: 'flex', justifyContent: 'center', alignItems: 'center',
gap: '2rem', flexDirection: 'column', paddingTop: '4rem'
}}
>
<Typography variant="h4" align="center">
Yönetici Paneli Girişi
</Typography>
<Typography variant="h6" align="center">
Pin Giriniz
</Typography>
<Box>
<PinField
ref={pinRef}
autoFocus={true}
type="password"
className="pin-field pin-code"
length={6}
validate="0123456789"
inputMode='numeric'
onComplete={handleLogin}
disabled={pending}
/>
</Box>
{
error && <Typography variant="body1" color='error' align="center">
HATALI PİN!
</Typography>
}
</Box>
</Container>
)
}
My axios instance are here: /lib/axios.ts. nextApi instance is for my internal api and blogApi instance is for my backend.
import axios from 'axios'
// axios instance for next js internal api
export const nextApi = axios.create({
baseURL: '/api',
withCredentials: true,
})
// axios instance for my blog api
export const blogApi = axios.create({
baseURL: process.env.NEXT_PUBLIC_ENV === 'dev'
? process.env.NEXT_PUBLIC_BLOG_API_BASE_URL_DEV
: process.env.NEXT_PUBLIC_BLOG_API_BASE_URL_PRODUCTION,
})
And my next js .env.local file. I hide some of them. The pin code here is hashed.
NEXT_PUBLIC_ENV="dev"
NEXT_PUBLIC_BASE_URL_DEV="http://localhost:3000"
NEXT_PUBLIC_BASE_URL_PRODUCTION="http://localhost:3000"
NEXT_PUBLIC_API_KEY=""
NEXT_PUBLIC_BLOG_API_BASE_URL_DEV="http://localhost:8000/api"
NEXT_PUBLIC_BLOG_API_BASE_URL_PRODUCTION="http://localhost:8000/api"
NEXT_PUBLIC_SECRET=""
NEXT_PUBLIC_ADMIN_PIN=""
I shall put here the constants
export const ACCESS_TOKEN_NAME = 'accessToken'
export const REFRESH_TOKEN_NAME = 'refreshToken'
export const ADMIN_ACCESS_TOKEN_PAYLOAD: Readonly<any> = { role: 'admin' }
export const ADMIN_ACCESS_TOKEN_EXPIRE: Readonly<string> = '1h'
export const ADMIN_REFRESH_TOKEN_EXPIRE: Readonly<string> = '7d'
export const ADMIN_ACCESS_COOKIE_CONFIG: Readonly<any> =
{ httpOnly: true, maxAge: 3600, path: '/' }
export const ADMIN_REFRESH_COOKIE_CONFIG: Readonly<any> =
{ httpOnly: true, maxAge: 7 * 24 * 60 * 60, path: '/' }
// Next js internal api routes
const api_login = '/auth/login'
const api_logout = '/auth/logout'
const api_refreshLogin = '/auth/refresh'
const admin = '/admin'
const admin_login = '/login'
const admin_posts = '/posts'
const admin_createPost = '/create_post'
const admin_tags = '/tags'
const admin_comments = '/comments'
const blog = '/blog'
const blog_post = '/post'
export const routeMap = {
root: `/`,
api: {
login: {
root: api_login,
},
refreshLogin: {
root: api_refreshLogin,
},
logOut: {
root: api_logout,
},
},
admin: {
root: `${admin}`,
login: {
root: `${admin + admin_login}`
},
posts: {
root: `${admin + admin_posts}`,
createPost: `${admin + admin_posts + admin_createPost}`,
},
tags: {
root: `${admin + admin_tags}`,
},
comments: {
root: `${admin + admin_comments}`
}
},
blog: {
root: `${blog}`,
post: {
root: `${blog + blog_post}`
}
}
}
/api/auth/login/route.ts. This route receives the pin code from the admin, hashes it, and compares it to the pin from the .env.local file. If they match, it signs the access and refresh tokens, sets the access cookie for 1 hour, and the refresh cookie for 7 days, then returns a 200 OK response. Otherwise, it returns a 401 Unauthorized response.
import { NextRequest, NextResponse } from 'next/server'
import { sha256, signJWT } from '@/utils'
import {
ACCESS_TOKEN_NAME,
REFRESH_TOKEN_NAME,
ADMIN_ACCESS_TOKEN_PAYLOAD,
ADMIN_ACCESS_TOKEN_EXPIRE,
ADMIN_REFRESH_TOKEN_EXPIRE,
ADMIN_ACCESS_COOKIE_CONFIG,
ADMIN_REFRESH_COOKIE_CONFIG,
} from '../constants'
// Login next api route
export async function POST(req: NextRequest) {
const { pin } = await req.json()
const adminPin = process.env.NEXT_PUBLIC_ADMIN_PIN
const hashedPin = await sha256(pin)
if (hashedPin === adminPin) {
try {
const accessToken = await signJWT(
ADMIN_ACCESS_TOKEN_PAYLOAD,
process.env.NEXT_PUBLIC_SECRET! as string,
ADMIN_ACCESS_TOKEN_EXPIRE
)
const refreshToken = await signJWT(
ADMIN_ACCESS_TOKEN_PAYLOAD,
process.env.NEXT_PUBLIC_SECRET! as string,
ADMIN_REFRESH_TOKEN_EXPIRE
)
const response = NextResponse.json({ message: 'Authenticated' }, { status: 200 })
response.cookies.set(
ACCESS_TOKEN_NAME,
accessToken,
ADMIN_ACCESS_COOKIE_CONFIG
)
response.cookies.set(
REFRESH_TOKEN_NAME,
refreshToken,
ADMIN_REFRESH_COOKIE_CONFIG
)
return response
} catch (_) {
return NextResponse.json({ message: 'Invalid PIN' }, { status: 401 })
}
} else {
return NextResponse.json({ message: 'Invalid PIN' }, { status: 401 })
}
}
Next js middleware is located /src/middleware.ts
I'm using the middleware to protect admin panel routes. If there is an access token cookie, everything continues smoothly. Otherwise, it checks for a refresh token and validates the JWT. If the refresh token is valid, the middleware requests the refresh API route. If this request is successful, it allows access. Otherwise, it redirects to the admin panel login page.
import { NextRequest, NextResponse } from 'next/server'
import { verifyJWT } from '@/utils'
import { routeMap } from './app/(admin)/routeMap'
import { nextApi } from '@/lib/axios'
import { ACCESS_TOKEN_NAME, REFRESH_TOKEN_NAME } from './app/api/auth/constants'
export async function middleware(req: NextRequest) {
const accessToken = await verifyJWT(
req.cookies.get(ACCESS_TOKEN_NAME)?.value ?? '',
process.env.NEXT_PUBLIC_SECRET! as string
)
if (accessToken) { return NextResponse.next() }
const refreshToken = await verifyJWT(
req.cookies.get(REFRESH_TOKEN_NAME)?.value ?? '',
process.env.NEXT_PUBLIC_SECRET! as string
)
if (refreshToken)
{
const res = await nextApi.post(
process.env.NEXT_PUBLIC_ENV === 'dev'
? process.env.NEXT_PUBLIC_BASE_URL_DEV + '/api' + routeMap.api.refreshLogin.root
: process.env.NEXT_PUBLIC_BASE_URL_PRODUCTION + '/api' + routeMap.api.refreshLogin.root,
{ refreshToken }
)
const decoded = res.headers['set-cookie']!.toString().split(';')[0].split('=')[1]
if (await verifyJWT(decoded, process.env.NEXT_PUBLIC_SECRET! as string)) {
return NextResponse.next()
}
}
return NextResponse.redirect(new URL(routeMap.admin.login.root, req.url))
}
// admin routes except login route
export const config = { matcher: ['/admin((?!/login).*)'] }
/api/auth/refresh/route.ts It generates a new access token using the refresh token.
import { NextRequest, NextResponse } from 'next/server'
import { signJWT } from '@/utils'
import {
ACCESS_TOKEN_NAME,
ADMIN_ACCESS_COOKIE_CONFIG,
ADMIN_ACCESS_TOKEN_EXPIRE
} from '../constants'
export async function POST(req: NextRequest) {
const { refreshToken } = await req.json()
if (refreshToken) {
try {
const newAccessToken = await signJWT(
{ role: (refreshToken as any).role },
process.env.NEXT_PUBLIC_SECRET! as string,
ADMIN_ACCESS_TOKEN_EXPIRE
)
const response = NextResponse.json({ message: 'Token refreshed' }, { status: 200 })
response.cookies.set(
ACCESS_TOKEN_NAME,
newAccessToken,
ADMIN_ACCESS_COOKIE_CONFIG
)
return response
} catch (_) {
return NextResponse.json({ message: 'Invalid refresh token' }, { status: 401 })
}
} else {
return NextResponse.json({ message: 'Invalid refresh token' }, { status: 401 })
}
}
/api/auth/logout/route.ts It removes access and refresh token cookies
import { NextResponse } from 'next/server'
import { ACCESS_TOKEN_NAME, REFRESH_TOKEN_NAME } from '../constants'
export async function GET() {
try {
const response = NextResponse.json({ message: 'Logged out.' }, { status: 200 })
response.cookies.set(ACCESS_TOKEN_NAME, '', { maxAge: 0 })
response.cookies.set(REFRESH_TOKEN_NAME, '', { maxAge: 0 })
return response
} catch (_) {
return NextResponse.json({ message: 'Logout failed' }, { status: 500 })
}
}
and finally logout button onclick event:
async function handleLogout() { setLogOutProcess(true) nextApi.get(routeMap.api.logOut.root) .then(_ => router.push(routeMap.admin.login.root)) .catch(_ => alert('Sunucu hatası!')) .finally(() => setLogOutProcess(false)) }
Subscribe to my newsletter
Read articles from caner demirci directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
caner demirci
caner demirci
Programming is fun. It is nice to spend time with it and not realize how time passes, learning new things... I hope one day I will make awesome things with it.