How to use Supabase Auth with a backend on Render
Your users already sign in with Supabase Auth, and your API already runs on Render. Each request raises two questions: is the token real, and what data may this user read?
This guide answers both with two decisions:
- Verify tokens locally. Your Render service checks each token's signature against cached public keys, so it doesn't call Supabase on every request.
- Choose a data path. Either forward the user's token so your RLS (row-level security) policies stay in charge, or use a secret key on the server and enforce authorization in your own code.
To replace Supabase Auth instead, see the Render guide Migrating from Supabase. For background on sessions, tokens, and third-party identity providers, see Render's guide on how to implement authentication and authorization.
Before you start:
- Your users sign in with Supabase Auth.
- Your API runs as a Render web service.
- You can decode one of your own access tokens with a local script or a JWT debugger you trust.
How a request flows
- The client signs in with Supabase and receives an access token.
- The client calls your Render API with
Authorization: Bearer <token>. - The API verifies the token locally against public keys cached in memory.
- The API reads data from Supabase (Path A or B) or from Render Postgres over the internal URL.
Store Supabase keys in Render environment variables
Environment variables keep credentials such as API keys and connection strings out of your source code. Environment groups let you share one set of variables across several Render services. For the mechanics, see Render's environment variables docs. For more on handling secrets, see how Render handles secrets and environment variables. This section covers only the Supabase-specific layout.
Variable | Value | Needed when |
|---|---|---|
SUPABASE_URL | Your Supabase project URL | Always |
SUPABASE_JWKS_URL | https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json | Asymmetric signing keys |
SUPABASE_JWT_ALGS | The asymmetric alg value(s) from your token header | Asymmetric signing keys |
SUPABASE_JWT_ISSUER | https://<project-ref>.supabase.co/auth/v1 | Always |
SUPABASE_JWT_AUDIENCE | The aud value from one of your tokens | Always |
SUPABASE_JWT_SECRET | Legacy shared secret | Legacy projects only |
SUPABASE_CLIENT_KEY | The publishable key ( sb_publishable_...) or the legacy anon key | Path A (token forwarding) |
SUPABASE_SECRET_KEY | A secret key ( sb_secret_...), or the legacy service_role key on older projects | Path B only |
DATABASE_URL | Render Postgres internal URL | App data on Render Postgres |
Put the shared, non-secret values in an environment group, such as supabase-shared, and link it to the backend services that need it. Add SUPABASE_SECRET_KEY and SUPABASE_JWT_SECRET only to the individual backend services that use them, or to a separate group that you link only to backends.
Environment variable values are always strings. Validate them once at startup so a missing value stops the deploy instead of failing on the first request. This minimal config.ts requires only what every service needs and exposes path-specific keys through getters:
Call each getter at module load in the module that uses it, as the samples below do. A service using Path A or Path B still fails at startup when its key is missing, and a service using neither doesn't need either key.
Verify tokens locally with JWKS
With asymmetric signing, the issuer signs each token with a private key and publishes the matching public keys as a JWKS (JSON Web Key Set) document, defined in RFC 7517.
To confirm which scheme your project uses, decode a token header:
- An
algsuch asES256orRS256, together with akid(key ID), means asymmetric. Use this section. - An
algthat starts withHSmeans a symmetric shared secret. Skip to the legacy section.
Cache keys and handle rotation
supabase-js also offers supabase.auth.getClaims(), which verifies JWTs issued by Supabase Auth. This guide uses jose so each check is explicit.
Fetch the JWKS once and keep it in process memory. The jose library's createRemoteJWKSet handles this:
- It caches fetched keys.
- It refetches when the cache ages out (
cacheMaxAge, which defaults to 10 minutes). - It refetches when a token arrives with an unseen
kid, rate-limited bycooldownDuration, which defaults to 30 seconds after a successful fetch.
Supabase rotates signing keys in stages. A new signing key starts as a standby key. Rotating makes it the current key and moves the old one to "previously used", and Supabase keeps trusting both, so tokens signed with either key keep verifying. Revoke the previously used key once every token it signed has expired: with a 1-hour token expiry, Supabase says to wait at least 1 hour 15 minutes. Supabase's edge caches the JWKS endpoint for 10 minutes, so a revoked key can keep verifying for up to 10 minutes plus your cacheMaxAge.
Build the key set at module load, not per request, or you lose the cache.
Check claims after the signature
A valid signature isn't enough. Also check:
- Expiry (
exp):josechecks this by default. - Issuer (
iss) and audience (aud): Compare them againstSUPABASE_JWT_ISSUERandSUPABASE_JWT_AUDIENCE. - Subject (
sub): The standard JWT subject claim from RFC 7519. Treat it as the user ID.
This simplified Express middleware takes the verifier and an optional post-verification hook as arguments, so the legacy and Render Postgres sections can plug into it:
If every request returns 401, compare your issuer and audience values character for character (including trailing slashes), and confirm that SUPABASE_JWT_ALGS matches the header's alg.
If your project still uses the legacy shared secret
Verification works differently on projects that still sign tokens with a legacy shared secret:
- Symmetric signing: The header's
algis anHSvalue. You have no JWKS to fetch and nokidto track. - The secret lives on your API: Store it as
SUPABASE_JWT_SECRETon backend services only. - The secret can mint tokens: Anyone holding it can create valid tokens. Guard it as carefully as the secret key.
- No in-place rotation: Supabase no longer lets you rotate the legacy JWT secret. Regenerating it immediately invalidates current API keys and severs connections. Migrate to asymmetric signing keys to rotate without downtime.
For projects on a shared secret (HS256), Supabase recommends verifying the access token with the Auth server rather than locally. Local verification with the secret works, but it puts a token-minting secret on your API.
When your project moves to asymmetric keys, tokens start arriving with an asymmetric alg and a kid. During that transition, one verifier can accept both schemes. This sketch adds a legacy branch with the same issuer, audience, and subject checks as the JWKS branch:
Both branches pin their algorithms. Never let the token's header decide which key type to trust. After the last legacy-signed token expires, remove the HS branch and delete SUPABASE_JWT_SECRET from your services.
Wire the verifier into the requireUser middleware that your routes import:
Read data: forward the user's token or use a secret key
Path A: forward the user's token
Create a Supabase client per request with the client key, and pass the user's access token in the Authorization header. The request carries the user's identity instead of a privileged key, so your RLS policies for signed-in users, for example policies that compare rows to auth.uid(), decide what comes back. Your backend can add logic but can't widen access beyond those policies.
Path B: use a secret key server-side
Create one server-only client with a secret key (sb_secret_...), or the legacy service_role key on older projects. Supabase is deprecating service_role by the end of 2026. Both bypass every RLS policy. Treat it as an admin connection that RLS doesn't restrict. Your code becomes the authorization layer, so scope every query to the verified user ID.
Path | Key used | RLS applies? | Where authorization lives | Typical uses | Main risk |
|---|---|---|---|---|---|
A: forward token | Client key + user's access token | Yes | RLS policies | User-scoped reads and writes | Gaps in RLS policies |
B: secret key | Secret key | No. Bypasses every RLS policy | Your backend code | Admin tasks, cross-user jobs, aggregates | A missing filter exposes other users' data |
Default to forwarding the user's token. Use a secret key deliberately, in narrow, well-reviewed code paths.
Store app data on Render Postgres, keyed to Supabase user IDs
If your app data lives on Render Postgres, store the verified token's sub value in your own tables. Your application keeps that link consistent:
- Keep a local
app_userstable keyed by the Supabase user ID. - On authenticated requests, upsert a row into it, copying only the claims you need.
- Point your other tables at
app_users.idwith real foreign keys inside Render Postgres.
The code below wires the transition verifier to a hook that upserts the user after each successful verification.
The upsert never sees a deleted user. To catch deletions, run a periodic reconciliation job, such as a Render cron job. The job imports config.ts through db.ts and supabaseAdmin.server.ts, so it needs the same startup variables as your API, or it fails at startup:
- Link the
supabase-sharedgroup forSUPABASE_URL,SUPABASE_JWT_ISSUER,SUPABASE_JWT_AUDIENCE,SUPABASE_JWKS_URL, andSUPABASE_JWT_ALGS. On a legacy project, setSUPABASE_JWT_SECRETdirectly on the job instead of the JWKS variables. - Set
DATABASE_URLandSUPABASE_SECRET_KEYon the job.
If your API uses only Path A, the job is the only service that holds the secret key. Follow three safety rules:
- Page through every user, and abort on any error. Never reconcile against a partial list.
- Soft-delete first. Flag missing users with
deleted_atand hard-delete only after a grace period, so you can recover from a bad run. - Skip rows touched during the run. A user who signs up mid-run isn't in the listing yet.
Two Render details matter here:
- Use the internal URL. Set
DATABASE_URLto the database's internal URL, which serves connections only from Render services in the same region, so create the database in your web service's region. The internal URL minimizes query latency because traffic stays on your private network. - Pool connections when you need more. If you outgrow your plan's connection limit, enable PgBouncer connection pooling (paid database plans only) and point
DATABASE_URLat the pool URL, which uses port 6432.
As with Path B, your code authorizes every query against Render Postgres, so always filter by owner_id = $userId.
Where to go next
You now have three building blocks:
- Local verification: Verify tokens against cached JWKS keys, or against the legacy shared secret during a transition.
- A data path per use case: Forward the user's token by default, and use a secret key narrowly.
- App data keyed to Supabase users: Key Render Postgres rows to Supabase user IDs, with a reconciliation job for deletions.
Next, revisit Render's guide on how to implement authentication and authorization, review environment variables and secrets, and read Migrating from Supabase if you ever decide to replace Supabase Auth.