Who can do what
Your server works out who is calling. The schema says what they may do. Rayfold enforces those rules on every request, before it runs and while it runs. Resolvers never repeat a permission check, and a new field cannot forget one.
Who is calling
Rayfold does not sign anyone in. Your identity provider does (Auth0, Entra ID, Keycloak, Cognito, or your own), and issues the client a signed access token. The server checks that token on every request and turns the claims it signed into the viewer, the object the schema's rules see as viewer. The client never tells the server who it is or what role it has: a role comes only from a token whose signature, issuer, audience and expiry all check out.
// In production, AUTH_JWKS_URL and AUTH_ISSUER name your identity provider (Auth0, Entra ID, Keycloak, Cognito, ...).
// The server fetches its published signing keys, caches them and follows its key rotation.
const jwks = process.env["AUTH_JWKS_URL"] ? createRemoteJWKSet(new URL(process.env["AUTH_JWKS_URL"])) : undefined;
const issuer = jwks ? process.env["AUTH_ISSUER"] : DEV_ISSUER;
if (!issuer) throw new Error("AUTH_ISSUER must be set with AUTH_JWKS_URL: a token from any other issuer is refused");
// checks the signature, the issuer, the audience and the expiry; the claims are read only once all of them pass
const verify = (token: string) =>
jwks ? jwtVerify(token, jwks, { issuer, audience: "bookshop" }) : jwtVerify(token, DEV_SECRET, { issuer, audience: "bookshop" });
export async function viewerFrom(authorization: string | undefined): Promise<Viewer | null> {
if (!authorization) return null; // no token: an anonymous caller, who may still read what the schema allows
const token = /^Bearer (\S+)$/.exec(authorization)?.[1];
if (!token) throw new RayfoldError("unauthenticated", "Expected Authorization: Bearer <token>");
try {
const { payload } = await verify(token);
// `role` is the claim this provider carries roles in; the schema's rules compare against what it maps to
return { id: String(payload.sub), role: payload["role"] === "staff" ? "staff" : "customer" };
} catch {
throw new RayfoldError("unauthenticated", "Invalid or expired token");
}
}// In production, AUTH_JWKS_URL and AUTH_ISSUER name your identity provider (Auth0, Entra ID, Keycloak, Cognito, ...).
// Nimbus fetches its published signing keys, caches them and follows its key rotation.
private val jwks: String? = System.getenv("AUTH_JWKS_URL")
private val tokens = DefaultJWTProcessor<SecurityContext>().apply {
jwsKeySelector = if (jwks != null) {
JWSVerificationKeySelector(JWSAlgorithm.RS256, JWKSourceBuilder.create<SecurityContext>(URI(jwks).toURL()).build())
} else {
JWSVerificationKeySelector(JWSAlgorithm.HS256, ImmutableSecret(DEV_SECRET))
}
val issuer = if (jwks != null) System.getenv("AUTH_ISSUER") ?: error("AUTH_ISSUER must be set with AUTH_JWKS_URL") else DEV_ISSUER
// the audience, the issuer, a subject and an expiry are required; the expiry is checked against the clock
jwtClaimsSetVerifier = DefaultJWTClaimsVerifier(setOf("bookshop"), JWTClaimsSet.Builder().issuer(issuer).build(), setOf("sub", "exp"), null)
}
/** The viewer the schema's policies see as `viewer`, or null for an anonymous request. */
fun viewerOf(exchange: HttpExchange): JsonElement {
val authorization = exchange.requestHeaders.getFirst("Authorization") ?: return JsonNull
val token = Regex("""^Bearer (\S+)$""").find(authorization)?.groupValues?.get(1)
?: throw RayfoldException(Code.UNAUTHENTICATED, "Expected Authorization: Bearer <token>")
val claims = try {
tokens.process(token, null) // checks the signature and the claims above; nothing is read before both pass
} catch (e: Exception) {
throw RayfoldException(Code.UNAUTHENTICATED, "Invalid or expired token")
}
// `role` is the claim this provider carries roles in; the schema's rules compare against what it maps to
return buildJsonObject {
put("id", claims.subject)
put("role", if (claims.getStringClaim("role") == "staff") "staff" else "customer")
}
}// In production, AUTH_JWKS_URL and AUTH_ISSUER name your identity provider (Auth0, Entra ID, Keycloak, Cognito, ...).
// Nimbus fetches its published signing keys, caches them and follows its key rotation.
private static final DefaultJWTProcessor<SecurityContext> TOKENS = tokens(System.getenv("AUTH_JWKS_URL"), System.getenv("AUTH_ISSUER"));
private static DefaultJWTProcessor<SecurityContext> tokens(String jwks, String issuer) {
if (jwks != null && issuer == null) throw new IllegalStateException("AUTH_ISSUER must be set with AUTH_JWKS_URL");
var tokens = new DefaultJWTProcessor<SecurityContext>();
try {
tokens.setJWSKeySelector(jwks != null
? new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, JWKSourceBuilder.<SecurityContext>create(URI.create(jwks).toURL()).build())
: new JWSVerificationKeySelector<>(JWSAlgorithm.HS256, new ImmutableSecret<>(DEV_SECRET)));
} catch (MalformedURLException e) {
throw new IllegalArgumentException("AUTH_JWKS_URL is not a URL: " + jwks, e);
}
// the audience, the issuer, a subject and an expiry are required; the expiry is checked against the clock
tokens.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(Set.of("bookshop"),
new JWTClaimsSet.Builder().issuer(jwks != null ? issuer : DEV_ISSUER).build(), Set.of("sub", "exp"), null));
return tokens;
}
/** The viewer the schema's policies see as {@code viewer}, or null for an anonymous request. */
static Map<String, String> viewerOf(HttpExchange exchange) {
String authorization = exchange.getRequestHeaders().getFirst("Authorization");
if (authorization == null) return null;
Matcher bearer = BEARER.matcher(authorization);
if (!bearer.matches()) throw Rayfold.error(Code.UNAUTHENTICATED, "Expected Authorization: Bearer <token>");
JWTClaimsSet claims;
try {
claims = TOKENS.process(bearer.group(1), null); // checks the signature and the claims above; nothing is read before both pass
} catch (Exception e) {
throw Rayfold.error(Code.UNAUTHENTICATED, "Invalid or expired token");
}
// `role` is the claim this provider carries roles in; the schema's rules compare against what it maps to
return Map.of("id", claims.getSubject(), "role", "staff".equals(claims.getClaim("role")) ? "staff" : "customer");
}@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(requests -> requests.anyRequest().permitAll())
// every bearer token is a JWT whose signature, issuer, audience and expiry Spring checks before the request
// goes on; the issuer and its signing keys are configured in application.properties
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(roles())))
// Rayfold checks the Origin and content type of every request that can change data itself
.csrf(csrf -> csrf.ignoringRequestMatchers("/rayfold/**"))
.build();
}
// The starter turns the signed-in principal into the viewer: its name, the token's `sub`, is viewer.id, and its
// first role viewer.role. `role` is the claim this provider carries roles in.
private static JwtAuthenticationConverter roles() {
var authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("role");
authorities.setAuthorityPrefix("ROLE_");
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
}- The keys come from the provider's published key set (JWKS), fetched once, cached, and refreshed when the provider rotates them. No shared secret is copied into the server.
- A request without a token is anonymous: the viewer is
null, and it can still do whatever the rules allow anonymous callers. A token that is present but does not verify is refused withunauthenticated(HTTP 401) before anything runs. - The viewer can be any object. Here it is the token's subject and its
roleclaim; use whichever claims your provider puts roles, groups or tenants in. A session cookie works the same way: look the session up, and return its user. - In Spring Boot you do not write this function. Spring Security verifies the token, and the starter builds the viewer from the authenticated principal: its name becomes
viewer.id, its rolesviewer.roles, and the first of themviewer.role.
Without a provider configured, the examples sign and check tokens with a development key, so they run on their own. npm run token -- staff, ./gradlew -q token --args=staff, or ./mvnw -q compile exec:java -Dexec.args=staff in the Spring Boot example prints one. Setting AUTH_JWKS_URL (which needs AUTH_ISSUER beside it) turns the development key off; in Spring Boot, setting spring.security.oauth2.resourceserver.jwt.issuer-uri does.
The server calls the function once per request, or once per WebSocket connection:
const schema = readFileSync(new URL("./bookshop.rayfold", import.meta.url), "utf8");
export function createBookshop(store: Store = seed()): { server: RayfoldServer; store: Store } {
return { server: createRayfoldServer({ schema, resolvers: resolvers(store) }), store };
}
export function bookshopHttp(server: RayfoldServer): Server {
const endpoint = createHttpHandler(server, { viewer: (req) => viewerFrom(req.headers.authorization) });
const explorer = createExplorerHandler({ endpoint: "/rayfold", title: "Bookshop" });
return createServer((req, res) => {
if (explorer(req, res)) return;
if (req.url?.startsWith("/rayfold")) return void endpoint(req, res);
res.writeHead(404).end();
});
}fun startServer(port: Int, store: Store = Store()): HttpServer {
val schema = SchemaText.load(resourceText("/bookshop.rayfold")).ir
val server = RayfoldServer(schema, resolvers(store))
val options = HttpOptions(explorer = true, explorerTitle = "Bookshop")
return RayfoldHttp(server, options, ::viewerOf).start(port)
}public static HttpServer start(int port, Store store) throws IOException {
return Rayfold.http(server(store))
.viewer(Auth::viewerOf)
.explorer("Bookshop")
.start(port);
}Rules in the schema
entity Book @cache(maxAge: 60s, scope: public) {
id: ID
title: String
stock: Int
author: Author
costPrice: Decimal? @allow(read: viewer.role == "staff")
}
command buy(bookId: ID, qty: Int = 1 @range(min: 1, max: 10)): Book
throws OutOfStock
@allow(write: viewer != null)
command restock(bookId: ID, qty: Int @range(min: 1, max: 1000)): Book
@allow(write: viewer.role == "staff")readgoverns queries, streams, and the reading of fields inside any result, including a command's.writegoverns commands.- A rule can sit on an operation, a type or a field. A field is readable only if every level allows it, and
@denyoverrides@allow. - Rules are expressions over
viewer,this(the object being read) andargs, with==,!=,<,<=,>,>=,in,&&,||and!, and the built-in functionshas(),len()andnow(). A rule can depend on the row being read:
entity Order @allow(read: viewer.id == customerId || viewer.role == "admin") {
id: ID
customerId: ID
total: Decimal
}An expression that cannot be evaluated, such as comparing text with a boolean, fails closed: @allow does not allow. A path reads only an object's own fields, the same in both runtimes, so tags.length is null: write len(tags).
What a caller sees when a rule says no
| When the rule refuses | The caller gets |
|---|---|
| An operation, and nobody is signed in | unauthenticated: Sign in to access buy() |
| An operation | permission_denied: Not allowed to access restock() |
| A field the shape asks for | permission_denied with "path": "costPrice", and the operation fails as a whole |
A field the shape asks for, marked @partial | null for that field, and an entry in the frame's errors |
| A field that is only in the default view | the field is left out, with no error |
An entity at a position that may be null | null, exactly as if it did not exist |
An entity at a position that may not be null, or an element of a list | permission_denied with a path, and the operation fails as a whole |
At a position that may be null, a caller cannot tell an entity it may not see from one that does not exist.
Rules in the database too
A rule that only uses viewer, args, literals and plain fields of this can be handed to the loader as a filter. A list query then never loads rows the viewer may not see. A loader that ignores the filter still returns correct results, because the runtime filters afterwards. The Postgres adapter turns these rules into SQL.
Cost limits
The server works out what each batch can cost before it runs and refuses one over its per-batch budget (budget, 1000 by default) with resource_exhausted. The budget is the server's, the same for every caller. See what a query costs.
Narrow access for agents and services
A capability token lets an AI agent or another service act for a user in a limited way, without holding the user's credentials. It is signed, and it names the viewer, the operations it may call and when it expires. A token can be narrowed further before being handed on, but never widened. The runtime enforces the operation list, and the policies on this page still run on the viewer the token names.
Capability tokens shows how to mint, use and attenuate one; the authorization chapter defines the format.
Browsers
- A Rayfold server refuses any request that is not a safe read (commands, live queries, plain
POSTs) from a browser origin it does not know. This stops another website from acting for your users. List your web app's origin inallowedOrigins, as the React guide shows. - The batch endpoint reads only JSON and its binary format as request bodies, and the uploads route only
application/octet-stream. None of those is a type a plain HTML form on another site can send without asking permission first.
The security chapter lists every default.
Next
- Keep a query open and receive changes: Live updates.
- The errors a refused request comes back with: Errors.