Episode 13 implements authentication with JWT: the concept of authentication versus authorization, the header-payload-signature structure, login and signup mutations with bcrypt, context-based authentication, refresh token patterns with rotation, and OAuth integration.

Production APIs almost always protect some of their data. Episode 13 builds the first security layer: authentication — proving who the user is. We'll implement it with JWT (JSON Web Token), the de facto standard for stateless APIs.
We'll understand the concept of authentication versus authorization, dissect the JWT structure, build login and signup mutations with bcrypt, attach the user to the GraphQL context, and then learn about refresh token patterns and OAuth integration.
These two terms are often swapped:
This episode covers authentication; authorization with roles and permissions will be covered in episode 14.
Many GraphQL implementations reject traditional session cookies because GraphQL uses a single endpoint and is often consumed by mobile apps. The approach used is token-based stateless authentication: the server verifies the token on every request without storing a session on the server. This makes the server easy to scale horizontally (episode 34) and suits APIs consumed by many kinds of clients.
A JWT consists of three parts separated by dots: header.payload.signature.
sub (subject), iat (issued at), and exp (expiration).npm install jsonwebtoken bcryptjsimport jwt from "jsonwebtoken";
export function createToken(user) {
return jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
}import jwt from "jsonwebtoken";
export function verifyToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch {
throw new Error("Token tidak valid atau kedaluwarsa");
}
}Note that the JWT payload is not encrypted — it's only signed. Never put sensitive data like passwords in the token. The JWT_SECRET must be stored as an environment variable (read via process.env.JWT_SECRET), never hardcoded.
First, create the signup mutation with password hashing:
import bcrypt from "bcryptjs";
async function signup(_, args, ctx) {
const hashed = await bcrypt.hash(args.input.password, 10);
const user = await ctx.prisma.user.create({
data: { email: args.input.email, password: hashed },
});
return { token: createToken(user), user };
}Then the login mutation verifies the password:
async function login(_, args, ctx) {
const user = await ctx.prisma.user.findUnique({
where: { email: args.input.email },
});
if (!user) throw new Error("Email atau password salah");
const valid = await bcrypt.compare(args.input.password, user.password);
if (!valid) throw new Error("Email atau password salah");
return { token: createToken(user), user };
}Security note: use the same error message for "user doesn't exist" and "wrong password" so you don't reveal whether an email is registered. The bcrypt salt rounds of 10 give a good balance of speed and security.
Once a token is created, every request carries it in the Authorization header. The context function extracts and verifies it:
import { startStandaloneServer } from "@apollo/server/standalone";
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
const header = req.headers.authorization || "";
const token = header.replace("Bearer ", "");
if (!token) return { user: null };
try {
const payload = verifyToken(token);
const user = await prisma.user.findUnique({ where: { id: payload.sub } });
return { user };
} catch {
return { user: null };
}
},
});Every resolver can now read ctx.user to know who's logged in. A resolver that requires login just checks if (!ctx.user) throw new UnauthorizedError(). This checking pattern will be tidied into a reusable helper in episode 14.
Access tokens are short-lived (15 minutes) for security. To avoid re-login, use a long-lived refresh token stored in a safe place:
async function refreshToken(_, args, ctx) {
const stored = await ctx.db.refreshTokens.find(args.token);
if (!stored || stored.revoked) throw new Error("Refresh token tidak valid");
await ctx.db.refreshTokens.revoke(args.token);
const user = await ctx.db.users.find(stored.userId);
const newRefresh = createRefreshToken(user);
await ctx.db.refreshTokens.store(newRefresh, user.id);
return { accessToken: createToken(user), refreshToken: newRefresh };
}Also consider revocation: a list of deactivated tokens, or short-lived refresh tokens. Where refresh tokens are stored on the client (for example, in mobile secure storage) is an important security decision that will be revisited in episode 28.
For third-party provider login, the flow uses OAuth: the app is redirected to the provider (Google, GitHub), the provider returns an authorization code, the server exchanges it for a provider access token, then fetches the user profile:
redirect ke Google -> code -> server -> token + profil -> user dibuat/ditemukan -> JWT appGraphQL implementations usually add a mutation like loginWithGoogle(code: String!) that receives the authorization code from the client side. We'll discuss a real OAuth integration with NextAuth in episode 27.
Key takeaways:
In the next episode, episode 14, you'll learn about authorization and access control — field-level and object-level authorization patterns, Role-Based Access Control with role hierarchies, granular permissions, the @auth and @hasRole directives, reusable middleware, and data filtering based on ownership and scope. You'll have full control over who can access what!