Authenticate storefront customers on your own API
Let customers who sign in through the embedded storefront, or your app with its license, use your own API, and verify on your server who is calling.
Written By Tobias Lønnerød Madsen
When a customer signs in through the embedded storefront on your website, Moonbase gives their browser an access token. Your own API can accept that token too. Customers sign in once, with their Moonbase account, and your API knows exactly which customer it's talking to, without a second login or a user database of your own.
This guide walks through getting the token in the browser, sending it to your API, and verifying it on your server. It's written for developers, and assumes the storefront widget is already running on your site.
How it works
The customer signs in or signs up in the storefront widget on your site.
Moonbase gives the widget an access token. It's a JSON Web Token (JWT), signed with your account's private key, and it's valid for 15 minutes. The widget renews it for as long as the customer stays signed in.
Your page asks the widget for the token and sends it to your API.
Your API checks the token's signature with your account's public key, and checks what kind of token it is, who issued it and who it's for. If everything matches, your API knows which customer is calling.
Your API doesn't need to call Moonbase to do this, and it doesn't need an API key. The public key is all it needs.
Before you start
Your site is allowed to use the storefront. Its exact origin, such as
https://www.example.com, must be under Account settings → Storefront → Domain configuration. See Domain whitelist.Your Account ID. Find it under Account settings → Developer → Account details. It's also the first part of your Moonbase address: for
https://acme.moonbase.shit'sacme.Your API accepts requests from your site. If your API runs on a different origin than your pages, for example
https://api.example.com, its CORS settings must allow your site's origin and theAuthorizationheader. If you use cookies (step 4), they must allow credentials too.
Step 1: Get the token in the browser
Ask the widget for the signed-in customer's token:
const token = await Moonbase.getAccessToken()It returns null when nobody is signed in. When the token has less than half a minute left, the widget renews it before handing it over.
Ask for the token each time you need it. Don't keep a copy from page load: each token stops working 15 minutes after it was issued.
Use the widget's token rather than reading its storage. The widget keeps the customer's session in your site's browser storage, together with the refresh token that keeps them signed in. That refresh token must never leave the browser, and the way it's stored can change.
On a Vue site using
@moonbase.sh/vue,useAuth().getAccessToken()does the same.
Step 2: Send it to your API
Send the token in the Authorization header, as a bearer token:
async function callMyApi(path, options = {}) {
const token = await Moonbase.getAccessToken()
const headers = new Headers(options.headers)
if (token)
headers.set('Authorization', `Bearer ${token}`)
return fetch(`https://api.example.com${path}`, { ...options, headers })
}
const response = await callMyApi('/presets')Your API answers 401 when the header is missing or the token doesn't check out (step 3), and your page can then ask the customer to sign in with Moonbase.sign_in().
Step 3: Verify the token on your server
Before your API trusts a token, it has to check all of these. Most JWT libraries do it for you once you pass the right options:
Signature: signed with your account's key, using
RS256. Don't accept any other algorithm.Type (the
typheader):at+jwt.Issuer (
iss): your Account ID.Audience (
aud):https://moonbase.sh.Expiry (
exp): present, and not passed. Allow up to a minute of difference between your server's clock and ours.
Don't skip the type, audience and expiry checks. Your license tokens are signed with the same key and issued by the same Account ID, and a perpetual license has no expiry at all. An API that only checks the signature and the issuer would accept a customer's license file as a sign-in that never runs out.
Your public key is published as a JSON Web Key Set at https://<account-id>.moonbase.sh/api/key/jwks, which most JWT libraries can read directly. It never changes, so fetch it once and keep it, rather than fetching it for every request.
import { createRemoteJWKSet, jwtVerify } from 'jose'
const ACCOUNT_ID = 'acme'
// Fetched once and cached by jose.
const keys = createRemoteJWKSet(new URL(`https://${ACCOUNT_ID}.moonbase.sh/api/key/jwks`))
export async function verifyMoonbaseToken(token) {
const { payload } = await jwtVerify(token, keys, {
typ: 'at+jwt',
algorithms: ['RS256'],
issuer: ACCOUNT_ID,
audience: 'https://moonbase.sh',
requiredClaims: ['exp'],
clockTolerance: 30,
})
return {
customerId: payload.sub,
email: payload.email,
emailVerified: payload.email_verified,
name: payload.name,
}
}
// In an Express route:
app.get('/presets', async (req, res) => {
let customer
try {
customer = await verifyMoonbaseToken(req.get('Authorization')?.replace(/^Bearer /, ''))
}
catch {
return res.sendStatus(401)
}
res.json(await loadPresets(customer.customerId))
})using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
const string AccountId = "acme";
// Your key never changes, so fetch the set once when your API starts.
var keys = new JsonWebKeySet(
await new HttpClient().GetStringAsync($"https://{AccountId}.moonbase.sh/api/key/jwks"));
var parameters = new TokenValidationParameters
{
ValidTypes = ["at+jwt"],
ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
ValidIssuer = AccountId,
ValidAudience = "https://moonbase.sh",
IssuerSigningKeys = keys.GetSigningKeys(),
RequireExpirationTime = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
// Returns the customer ID, or null if the token doesn't check out.
async Task<string?> VerifyMoonbaseToken(string token)
{
var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, parameters);
return result.IsValid ? result.Claims["sub"] as string : null;
}The Node.js example uses the jose library, and the .NET example uses Microsoft.IdentityModel.JsonWebTokens. In ASP.NET Core you can pass the same parameters to AddJwtBearer; set MapInboundClaims = false there to read sub by its own name. Any JWT library that supports RS256 and JWK Sets works the same way.
The same key is also at https://<account-id>.moonbase.sh/api/key/public in PEM format, and under Account settings → Developer → Account details, for libraries that want a PEM key instead.
What the token tells you
sub: the customer's ID. It's the same ID the Moonbase API and webhooks use, and it never changes. Use it to identify the customer.email: the customer's email address when the token was issued.email_verified: whether the customer has confirmed that email address.name: the customer's name. Left out when they haven't given one.sid: the sign-in session the token belongs to. It stays the same while the widget renews the token.iss,aud,iat,nbfandexp: who issued the token, who it's for, and when it was issued and runs out.
Tokens also carry nameid and claims whose names start with https://moonbase.sh/claims/. Those are for Moonbase's own use: read the standard claims above instead.
The token doesn't say what the customer owns. To check that, ask the Moonbase API from your server with an API key, for example for the customer's licenses at https://<account-id>.moonbase.sh/api/customers/<customerId>/licenses.
Step 4: Keep a session of your own (optional)
Sending the token with every request is all most APIs need. If yours keeps its own sessions instead, for example with a cookie so that plain links and downloads work too, use the token once to start one:
Add an endpoint to your API, for example
POST /session. It verifies the token exactly as in step 3, and if the token checks out, signs the customer in to your API the way your API normally does. AddDELETE /sessionto end that session.Call it from your page whenever the widget's sign-in changes, and when the customer signs out:
let syncedToken
async function syncSession() {
const token = await Moonbase.getAccessToken()
if (token === syncedToken)
return
syncedToken = token
await fetch('https://api.example.com/session', {
method: token ? 'POST' : 'DELETE',
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
}
for (const event of ['storefront-updated', 'signed-in', 'signed-up', 'signed-out'])
Moonbase.on(event, syncSession)signed-in and signed-up fire on the page where the customer signs in, including through a link in an email. signed-out also fires when the session ends without anyone signing out on the page: when the customer signs out in another tab, or when the session was ended elsewhere, such as by a password change on another device. storefront-updated fires once the widget has loaded, which is when it picks up a customer who was already signed in.
If your API runs on a different site from your pages, browsers may block its cookies. In that case, keep sending the Moonbase token with each request (step 2) instead.
Calling your API from your app
Your desktop app or plugin can call the same API, with the license token the Moonbase licensing SDK keeps once your product is activated. It's signed with the same key and issued by the same Account ID, but it's a different kind of token: it says that this device has activated a license for your product, not that a customer has just signed in on your website.
Telling them apart
Type (the
typheader):at+jwtfor an access token,JWTfor a license token.Audience (
aud):https://moonbase.shfor an access token, your product ID for a license token.Customer:
subin an access token,u:idin a license token. They're the same ID. A trial can belong to nobody, and thenu:idis all zeros.Lifetime: an access token runs out after 15 minutes. A license token only has an
expwhen the license itself expires, so the token for a perpetual license never does.
Pick the check from the typ header, then verify the token with the check for that kind. Each check insists on its own type and audience, so neither kind of token can pass as the other.
Verifying a license token
Use the same key set and issuer as in step 3, a
typofJWT, and your product ID as the audience.Check
expwhen it's there, but don't require it.Check how recently Moonbase confirmed the activation. A license token keeps verifying after the license or the activation has been revoked.
validated(in seconds, likeexp) is when Moonbase last confirmed it online, so refuse tokens older than you're comfortable with, such as a week. When your API refuses one, your app validates the license online again, which gets it a new token. Offline activations (methodisOffline) are never validated online again, so a limit like this also keeps them out.trialistruefor a trial. Refuse those if the API is only for paying customers.
import { decodeProtectedHeader, jwtVerify } from 'jose'
// keys and ACCOUNT_ID as in step 3.
const PRODUCT_ID = 'acme-studio'
const MAX_LICENSE_AGE = 7 * 24 * 60 * 60 // a week, in seconds
export async function verifyLicenseToken(token) {
const { payload } = await jwtVerify(token, keys, {
typ: 'JWT',
algorithms: ['RS256'],
issuer: ACCOUNT_ID,
audience: PRODUCT_ID,
})
if (Date.now() / 1000 - Number(payload.validated) > MAX_LICENSE_AGE)
throw new Error('License not validated online recently')
return {
customerId: payload['u:id'],
licenseId: payload['l:id'],
trial: String(payload.trial) === 'true',
}
}
// One Authorization header for both kinds of token.
export async function verifyCaller(token) {
return decodeProtectedHeader(token).typ === 'at+jwt'
? { from: 'storefront', ...await verifyMoonbaseToken(token) }
: { from: 'app', ...await verifyLicenseToken(token) }
}Good to know
Identify customers by
sub, not by email. Customers can change their email address. Don't connect a Moonbase customer to an existing user in your system by email unlessemail_verifiedistrue.Treat the token like a password. It's the customer's Moonbase session: while it's valid, it also opens their account, purchases and licenses in Moonbase. Don't log it, store it, or pass it on.
Signing out ends the session everywhere it matters. When the customer signs out of the widget, Moonbase ends their session, and when they change or reset their password, Moonbase signs them out of the storefront on every other device and website. A token your API has already seen stays valid until it runs out, at most 15 minutes later. When a session ends somewhere else, the widget fires
signed-outonce it finds out, at the latest when it next renews the token. If you keep your own sessions, end them onsigned-out, as in step 4.Sessions end after a year without a visit. The customer then signs in again.
Custom domains change nothing. If your storefront runs on a custom domain, tokens are still issued by your Account ID and signed with the same key.
Troubleshooting
Your API rejects every token because of the signature
The key set and Account ID have to come from the same Moonbase account the widget is set up with. Tokens from a test account don't verify against your live account's key.
Your API rejects the token because of the issuer, audience or type
The issuer is your Account ID, in lowercase, without .moonbase.sh. The audience is exactly https://moonbase.sh, without a trailing slash. The type is at+jwt: if your library compares it to JWT by default, change that setting.
Your API rejects the token as expired
Your page sent a copy it kept from earlier. Call Moonbase.getAccessToken() right before each request. If it happens on fresh tokens, check your server's clock.
Moonbase.getAccessToken() returns null, but the widget shows the customer as signed in
Sign-ins are kept per origin: a customer signed in on https://www.example.com isn't signed in on https://example.com. Make sure the page asking is on the same origin as the one where they signed in.
Moonbase.getAccessToken() says Moonbase has not been set up yet
Call it after Moonbase.setup(). With the script tag from Moonbase's CDN, calls made before the widget has loaded wait for it and answer afterwards.
The browser reports a CORS error when calling your API
That's your API's own CORS setting, not the Moonbase domain whitelist. Your API has to allow your site's origin and the Authorization header, and credentials if you use cookies.
Was this helpful?
More in Guides
Selling add-ons with sub-productsStill need help? Ask the team