Skip to content

Add JWT authentication

You’ll add a login endpoint that issues short-lived JWTs, configure the app to verify them on protected routes, and read the caller in your handlers. The same setup works for tokens from an external identity provider by swapping the key for a RemoteJwks.

  • A gisila project with a user store you can check passwords against.
  • A secret of at least 32 bytes: openssl rand -base64 48, exported as JWT_SECRET.

lib/auth/jwt.dart:

import 'dart:io';
import 'package:gisila/gisila.dart';
final _secret = Platform.environment['JWT_SECRET'] ??
(throw StateError('JWT_SECRET is not set'));
final jwtKey = JwtKey.hmac(_secret); // throws if shorter than 32 bytes
const jwtIssuer = 'notes-api';
final jwtSigner = JwtSigner(
key: jwtKey,
algorithm: 'HS256',
issuer: jwtIssuer,
expiresIn: const Duration(minutes: 15),
);
final jwtAuthenticator = JwtAuthenticator(
key: jwtKey,
algorithms: {'HS256'},
issuer: jwtIssuer,
);

The authenticator rejects tokens signed with any other algorithm, from another issuer, expired (with 30 seconds of leeway) or without exp.

final app = GisilaApp(config: AppConfig(authenticator: jwtAuthenticator));

Without toPrincipal, the token’s sub becomes principal.id, the roles claim becomes principal.roles, and scope plus permissions become principal.permissions.

class LoginForm extends Form {
final email = EmailField(name: 'email', required: true);
final password = StringField(name: 'password', required: true, trim: false);
@override
List<FormField<Object?>> collectFields() => [email, password];
}
class AuthService extends Service {
Future<Map<String, Object?>> login(String email, String password) async {
final user = await _users.findByEmail(email);
if (user == null || !await _passwords.verify(password, user.passwordHash)) {
throw Unauthorized('Invalid email or password');
}
final token = jwtSigner.sign(
subject: '${user.id}',
claims: {'roles': user.roles.toList(), 'scope': user.permissions.join(' ')},
);
return {'access_token': token, 'token_type': 'Bearer', 'expires_in': 900};
}
}
@Controller('/auth')
class AuthController {
@Post('/login', statusCode: 200, config: RouteConfig(
rateLimit: RateLimitConfig(requestsPerMinute: 10, burstSize: 5),
))
Future<Map<String, Object?>> login(LoginForm form, AuthService auth) =>
auth.login(form.email.value!, form.password.value!);
}

statusCode: 200 overrides @Post’s default 201. The rate limit slows down password guessing. Hash passwords with a slow algorithm (argon2, bcrypt, scrypt); gisila doesn’t ship one.

@Controller('/me')
@RequireAuth()
class MeController {
@Get('/')
Future<Map<String, Object?>> me(RequestContext ctx) async => {
'id': ctx.principal!.id,
'roles': ctx.principal!.roles.toList(),
'permissions': ctx.principal!.permissions.toList(),
};
@Get('/notes')
@Permissions({'notes:read'})
Future<List<Note>> notes(RequestContext ctx, NoteService notes) =>
notes.forOwner(ctx.principal!.id);
}

Load the user on every request to reject disabled accounts and attach data for handlers:

final jwtAuthenticator = JwtAuthenticator(
key: jwtKey,
algorithms: {'HS256'},
issuer: jwtIssuer,
toPrincipal: (claims, request) async {
final user = await userCache.find(claims.subject);
if (user == null || user.disabled) return null; // → 401 invalid_token
return Principal(
id: '${user.id}',
roles: user.roles,
permissions: claims.strings('scope'),
claims: {'user': user},
);
},
);

toPrincipal runs on every guarded request, so keep it fast (cache the lookup).

final jwtAuthenticator = JwtAuthenticator(
keys: RemoteJwks(Uri.parse('https://YOUR_DOMAIN/.well-known/jwks.json')),
algorithms: {'RS256'},
issuer: 'https://YOUR_DOMAIN/',
audience: {'https://api.example.com'},
);

Keys are cached for 10 minutes and refreshed early when a token names an unknown kid.

Terminal window
TOKEN=$(curl -s -X POST localhost:8080/auth/login -H 'content-type: application/json' \
-d '{"email":"ann@example.com","password":"correct horse"}' | jq -r .access_token)
curl -s localhost:8080/me -H "authorization: Bearer $TOKEN"
# {"id":"1","roles":["staff"],"permissions":["notes:read"]}
curl -si localhost:8080/me
# HTTP/1.1 401 ... {"error":{"status":401,"message":"Authentication required","code":"unauthorized"}}
curl -si localhost:8080/me -H 'authorization: Bearer nonsense'
# HTTP/1.1 401 ... {"error":{"status":401,"message":"Invalid token","code":"invalid_token"}}

An expired token answers 401 with code token_expired: the client’s cue to refresh or log in again.