Skip to main content
QeetMart uses JWT (JSON Web Tokens) with HMAC signing for stateless authentication. The auth service generates tokens, and the API Gateway validates them before forwarding requests to downstream services.

Authentication flow

The authentication flow follows a standard OAuth2-inspired pattern with access and refresh tokens:

Token types

QeetMart uses two types of JWT tokens:
Purpose: Short-lived token for API accessLifetime: 15 minutes (configurable via JWT_ACCESS_TOKEN_EXPIRATION)Claims:
{
  "sub": "[email protected]",
  "userId": 123,
  "role": "CUSTOMER",
  "email": "[email protected]",
  "tokenType": "access",
  "iss": "http://localhost:4001",
  "iat": 1709461800,
  "exp": 1709462700
}
Usage: Include in Authorization: Bearer {token} header for all API requests

JWT structure and signing

Token generation (Auth Service)

The auth service generates HMAC-signed JWTs using Spring Security:
public String generateToken(UserCredential user) {
    Map<String, Object> claims = new HashMap<>();
    claims.put(CLAIM_USER_ID, user.getId());
    claims.put(CLAIM_ROLE, user.getRole().name());
    claims.put(CLAIM_EMAIL, user.getEmail());
    claims.put(CLAIM_TOKEN_TYPE, TOKEN_TYPE_ACCESS);
    return jwtTokenSupport.generateToken(
        claims, 
        user.getEmail(), 
        jwtProperties.getAccessTokenExpiration()
    );
}
From micros/auth-service/src/main/java/com/qeetmart/auth/infrastructure/security/token/AccessTokenService.java:25
The sub (subject) claim contains the user’s email address. The userId is stored as a custom claim for efficient lookups.

Token validation (API Gateway)

The gateway validates JWTs using a custom HMAC verification implementation:
export const verifyJwtHmac = (
  token: string,
  secret: string,
  expectedIssuer?: string,
  expectedAudience?: string
): JwtClaims => {
  const segments = token.split('.');
  if (segments.length !== 3) {
    throw new Error('JWT must have exactly 3 segments');
  }

  const headerSegment = segments[0];
  const payloadSegment = segments[1];
  const signatureSegment = segments[2];
  
  // Verify signature
  const signingInput = `${headerSegment}.${payloadSegment}`;
  const expectedSignature = createHmac(
    HMAC_HASH_BY_JWT_ALG[algorithm], 
    parseSecret(secret)
  )
    .update(signingInput)
    .digest();
  const tokenSignature = decodeBase64Url(signatureSegment);

  if (!timingSafeEqual(expectedSignature, tokenSignature)) {
    throw new Error('JWT signature validation failed');
  }

  // Verify expiration
  if (typeof claims.exp !== 'number' || claims.exp <= nowSeconds) {
    throw new Error('JWT expired');
  }

  // Verify issuer
  if (expectedIssuer && claims.iss !== expectedIssuer) {
    throw new Error('JWT issuer mismatch');
  }

  return claims;
};
From micros/api-gateway/src/utils/jwt.ts:59
The gateway supports three HMAC algorithms:
  • HS256: HMAC with SHA-256 (default)
  • HS384: HMAC with SHA-384
  • HS512: HMAC with SHA-512
The algorithm is specified in the JWT header. Both gateway and auth service must use the same secret and algorithm.

Authentication middleware

The gateway applies authentication middleware to all routes except public endpoints:
const publicPathPrefixes = [
  '/health',
  '/info',
  '/api/v1/auth/register',
  '/api/v1/auth/login',
  '/api/v1/auth/refresh-token',
];

export const authMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  // Extract token from Authorization header
  const authHeader = req.headers.authorization;
  
  if (authHeader && authHeader.startsWith('Bearer ')) {
    const token = authHeader.substring(7);
    if (token) {
      req.token = token;
    }
  }

  const isPublicPath = publicPathPrefixes.some(path => 
    req.path.startsWith(path)
  );
  if (isPublicPath) {
    return next();
  }

  if (!req.token) {
    return res.status(401).json({
      success: false,
      error: {
        message: 'Authentication required',
        code: 'UNAUTHORIZED',
      },
    });
  }

  try {
    verifyJwtHmac(
      req.token, 
      jwtSecret, 
      gatewayConfig.jwt.issuer, 
      gatewayConfig.jwt.audience
    );
  } catch (error) {
    return res.status(401).json({
      success: false,
      error: {
        message: 'Invalid or expired token',
        code: 'UNAUTHORIZED',
      },
    });
  }

  next();
};
From micros/api-gateway/src/middleware/auth.middleware.ts:19

Registration and login

User registration

Registration creates a user credential record with a hashed password:
POST /api/v1/auth/register
Content-Type: application/json

{
  "email": "[email protected]",
  "password": "SecurePassword123!",
  "name": "John Doe"
}
Response:
{
  "success": true,
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "tokenType": "Bearer",
    "expiresIn": 900,
    "user": {
      "id": 123,
      "email": "[email protected]",
      "role": "CUSTOMER"
    }
  }
}

User login

POST /api/v1/auth/login
Content-Type: application/json

{
  "email": "[email protected]",
  "password": "SecurePassword123!"
}
Login validations:
  1. Credentials match
  2. Account not locked
  3. Email verified (if SECURITY_LOGIN_REQUIRE_EMAIL_VERIFIED=true)
  4. Failed login attempts not exceeded
After 5 failed login attempts (configurable via SECURITY_LOGIN_MAX_ATTEMPTS), the account is locked for 15 minutes (configurable via SECURITY_LOGIN_LOCK_DURATION_MINUTES).

Refresh token flow

POST /api/v1/auth/refresh-token
Content-Type: application/json

{
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Response:
{
  "success": true,
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "tokenType": "Bearer",
    "expiresIn": 900
  }
}
The refresh token is validated against the database to ensure it hasn’t been revoked.

User credential model

The auth service stores user credentials in PostgreSQL:
@Entity
@Table(name = "user_credentials")
public class UserCredential {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;  // CUSTOMER, ADMIN, STAFF

    @Enumerated(EnumType.STRING)
    @Column(name = "account_status", nullable = false)
    private AccountStatus accountStatus;  // ACTIVE, LOCKED, SUSPENDED

    @Column(name = "email_verified", nullable = false)
    private boolean emailVerified;

    @Column(name = "failed_login_attempts", nullable = false)
    private int failedLoginAttempts;

    @Column(name = "lock_until")
    private Instant lockUntil;

    @Column(name = "last_login_at")
    private Instant lastLoginAt;
    
    // timestamps...
}
From micros/auth-service/src/main/java/com/qeetmart/auth/domain/entity/UserCredential.java:32

Security configuration

Key security settings from .env files:
# JWT Configuration
JWT_SECRET=CHANGE_ME_TO_A_32_BYTE_MINIMUM_SECRET_123456
JWT_ISSUER_URI=http://localhost:4001
JWT_ACCESS_TOKEN_EXPIRATION=15m
JWT_REFRESH_TOKEN_EXPIRATION=7d

# Login Security
SECURITY_LOGIN_MAX_ATTEMPTS=5
SECURITY_LOGIN_LOCK_DURATION_MINUTES=15
SECURITY_LOGIN_REQUIRE_EMAIL_VERIFIED=false
SECURITY_LOGIN_SINGLE_SESSION_ENABLED=false
From micros/auth-service/.env.example
Critical: The JWT_SECRET must be identical in both the auth service and the API Gateway. In production, use a strong, randomly generated secret (32+ bytes recommended).

Session management

QeetMart supports optional single-session enforcement:
SECURITY_LOGIN_SINGLE_SESSION_ENABLED=false
When enabled:
  • Only one active refresh token per user is allowed
  • Logging in from a new device/browser invalidates previous sessions
  • Useful for high-security applications

Logout functionality

Logout current session

POST /api/v1/auth/logout
Authorization: Bearer {accessToken}
Content-Type: application/json

{
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Invalidates the provided refresh token.

Logout all sessions

POST /api/v1/auth/logout-all
Authorization: Bearer {accessToken}
Invalidates all refresh tokens for the authenticated user.

Password management

Password hashing

Passwords are hashed using BCrypt with Spring Security’s default strength (10 rounds):
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

Change password

POST /api/v1/auth/change-password
Authorization: Bearer {accessToken}
Content-Type: application/json

{
  "currentPassword": "OldPassword123!",
  "newPassword": "NewSecurePassword456!"
}
Validates the current password before updating.

Authorization and roles

QeetMart uses role-based access control (RBAC): Roles:
  • CUSTOMER: Regular users (default)
  • ADMIN: Full system access
  • STAFF: Limited administrative access
Downstream services can extract the role from the JWT to enforce authorization:
String role = jwtTokenSupport.extractAllClaims(token).get("role", String.class);
The gateway validates authentication (is the token valid?), but authorization (does this user have permission?) is enforced by individual services.This design allows:
  • Services to implement domain-specific authorization rules
  • Fine-grained permissions beyond simple roles
  • Independent service evolution without gateway changes

Token security best practices

HTTPS only

Always use HTTPS in production to prevent token interception

Short-lived access tokens

15-minute expiration limits exposure if tokens are compromised

Secure storage

Store tokens in httpOnly cookies or secure storage (not localStorage)

Token rotation

Use refresh tokens to obtain new access tokens regularly

Secret management

Never commit JWT secrets to version control

Audience validation

Use JWT_AUDIENCE to prevent tokens from other systems

Troubleshooting

”JWT signature validation failed”

Cause: Mismatch between auth service and gateway JWT secrets Solution: Ensure JWT_SECRET is identical in both services

”JWT expired”

Cause: Access token lifetime exceeded (15 minutes by default) Solution: Use the refresh token to obtain a new access token

”JWT issuer mismatch”

Cause: Token was issued by a different auth service Solution: Verify JWT_ISSUER / JWT_ISSUER_URI configuration matches

Next steps

API Gateway

Understand how the gateway validates and forwards tokens

Data Persistence

Explore user and authentication data models

Build docs developers (and LLMs) love