Skip to content

Session and API key auth

You’ll add two kinds of credentials to one API: server-side sessions in an HttpOnly cookie for your web frontend, and API keys for scripts and integrations. gisila ships ApiKeyAuthenticator; sessions use a small custom Authenticator backed by a CacheProvider.

  • A gisila project with a user store.
  • A cache: InMemoryCacheProvider for one instance, RedisCacheProvider from gisila_redis for several.
import 'package:gisila/gisila.dart';
final sessions = InMemoryCacheProvider(defaultTtl: const Duration(days: 7));
// Several instances: RedisCacheProvider(GisilaRedis.fromUrl(env['REDIS_URL']!))
import 'dart:convert';
import 'package:gisila/gisila.dart';
class SessionAuthenticator extends Authenticator {
SessionAuthenticator(this.sessions, {this.cookieName = 'sid'});
final CacheProvider sessions;
final String cookieName;
@override
Future<Principal?> authenticate(Request request) async {
final sid = readCookie(request, cookieName);
if (sid == null) return null; // anonymous → 401 on guarded routes
final raw = await sessions.get('session:$sid');
if (raw == null) {
throw HttpException(401, 'Session expired', code: 'session_expired');
}
final data = jsonDecode(raw) as Map<String, Object?>;
return Principal(
id: data['userId'] as String,
roles: {...(data['roles'] as List).cast<String>()},
permissions: {...(data['permissions'] as List).cast<String>()},
claims: {'sid': sid},
);
}
}
String? readCookie(Request request, String name) {
for (final part in (request.headers['cookie'] ?? '').split(';')) {
final i = part.indexOf('=');
if (i > 0 && part.substring(0, i).trim() == name) return part.substring(i + 1).trim();
}
return null;
}

Throwing an HttpException with status 401 sends that error (session_expired) to the client; any other exception becomes a generic 401.

class SessionService extends Service {
CacheProvider get _sessions => singleton<CacheProvider>();
Future<String> create(User user) async {
final sid = ApiKeyAuthenticator.generate(); // 32 random bytes, base64url
await _sessions.set('session:$sid', jsonEncode({
'userId': '${user.id}',
'roles': user.roles.toList(),
'permissions': user.permissions.toList(),
}), ttl: const Duration(days: 7));
return sid;
}
Future<void> destroy() async {
final sid = ctx.principal?.claims['sid'] as String?;
if (sid != null) await _sessions.delete('session:$sid');
}
}
@Controller('/session')
class SessionController {
@Post('/')
Future<Response> login(LoginForm form, AccountService accounts, SessionService sessions) async {
final user = await accounts.verify(form.email.value!, form.password.value!);
final sid = await sessions.create(user);
return jsonResponse(body: user, extraHeaders: {
'set-cookie': 'sid=$sid; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=604800',
});
}
@Delete('/')
@RequireAuth()
Future<Response> logout(SessionService sessions) async {
await sessions.destroy();
return Response(204, headers: {'set-cookie': 'sid=; Path=/; HttpOnly; Secure; Max-Age=0'});
}
}

Because the browser sends the cookie automatically, protect state-changing routes against cross-site requests: keep SameSite=Lax (or Strict), and with CORS list exact origins with allowCredentials: true.

Store only the hash. Show the key once.

class ApiKeyService extends Service {
Future<String> issue(Set<String> scopes) async {
final key = ApiKeyAuthenticator.generate(prefix: 'sk_live_');
await _apiKeys.insert(
hash: ApiKeyAuthenticator.hash(key),
ownerId: ctx.principal!.id,
scopes: scopes,
);
return key;
}
}
final apiKeys = ApiKeyAuthenticator(
lookup: (hash, request) async {
final row = await apiKeyStore.findActive(hash);
return row == null ? null : Principal(id: row.ownerId, permissions: row.scopes);
},
);

Clients send X-API-Key: sk_live_.... An unknown key is 401 invalid_api_key.

final app = GisilaApp(
config: AppConfig(
cache: sessions,
authenticator: CompositeAuthenticator([
SessionAuthenticator(sessions),
apiKeys,
]),
cors: const CorsConfig(allowedOrigins: {'https://app.example.com'}, allowCredentials: true),
),
);
app.registerSingleton<CacheProvider>(sessions);
app
..registerService<SessionService>(SessionService.new)
..registerService<ApiKeyService>(ApiKeyService.new);

The first authenticator that returns a principal wins. One that throws (an expired session, a wrong key) stops the search, so a bad credential is never retried as another type. You can use ctx.cache instead of the singleton inside services, since the same provider is set as AppConfig.cache.

Terminal window
curl -si -c jar -X POST localhost:8080/session -H 'content-type: application/json' \
-d '{"email":"ann@example.com","password":"correct horse"}'
# HTTP/1.1 200 OK ... set-cookie: sid=...; HttpOnly ...
curl -s -b jar localhost:8080/me
# {"id":"1", ...}
curl -s localhost:8080/me -H 'x-api-key: sk_live_wrong'
# {"error":{"status":401,"message":"Invalid API key","code":"invalid_api_key"}}